Effective Java 3/E 
Java라는 언어 자체의 패러다임을 바탕으로 좋은 Java 코드란 무엇인가를 이해하는데 많은 도움이 된 책이다.
추후 개발하면서 참고할 용도로 정리한다.
더 좋은 코드, 더 나은 코드를 짜는 개발자가 되고 싶다.
객체 생성과 파괴
생성자 대신 정적 팩터리 메서드를 고려하라
정적 팩터리 메서드 (static factory method)
public static Boolean valueOf(boolean b) {
return b ? Boolean.TRUE : Boolean.FALSE;
}
Java
복사
•
장점
◦
이름을 가질 수 있다.
▪
생성자의 경우, 매개변수와 생성자 자체만으로는 반환될 객체의 특성을 제대로 설명할 수 없다.
▪
반면, 정적 팩터리 메서드는 이름만 잘지으면 반환될 객체의 특성을 쉽게 묘사할 수 있다.
•
생성자: BigInteger(int, int, Random)
•
정적 팩터리 메서드: BigInteger.probablePrime
◦
호출될 때마다 인스턴스를 새로 생성하지 않아도 된다.
▪
인스턴스를 미리 만들어 놓거나, 새로 생성한 인스턴스를 캐싱하여 재활용하여 불필요한 객체 생성을 피할 수 있다.
◦
반환 타입의 하위 타입 객체를 반환할 수 있는 능력이 있다.
▪
반환할 객체의 클래스를 자유롭게 선택할 수 있게 한다. → 유연성
◦
입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다.
▪
반환 타입의 하위 타입이기만 하면 어떤 클래스의 객체를 반환해도 상관없다.
◦
정적 팩터리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 된다.
•
단점
◦
상속을 하려면 public이나 protected 생성자가 필요하니 정적 팩터리 메서드만 제공하면 하위 클래스를 만들 수 없다.
◦
정적 팩터리 메서드는 프로그래머가 찾기 어렵다.
▪
생성자처럼 API 설명에 명확히 드러나지 않으니, 사용자는 정적 팩터리 메서드 방식 클래스를 인스턴스화할 방법을 알아내야한다.
▪
메서드 이름을 널리 알려진 규약을 따라 지어 이런 문제를 완화한다.
•
from : 매개 변수를 하나 받아서 해당 타입의 인스턴스를 반환하는 형변환 메서드
◦
Date d = Date.from(instant);
•
of : 여러 매개변수를 받아 적합한 타입의 인스턴스를 반환하는 집계 메서드
◦
Set<Rank> faceCards = EnumSet.of(JACK, QUEEN, KING);
•
valueOf : from과 of의 더 자세한 버전
•
instance 혹은 getInstance : 매개변수로 명시한 인스턴스를 반환하지만, 같은 인스턴스 임을 보장하지는 않는다.
•
create 혹은 newInstance : instance 혹은 getInstance와 같지만, 매번 새로운 인스턴스를 생성해 반환함을 보장한다.
•
getType : getInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩터리 메서드를 정의할 때 쓴다. “Type”은 팩터리 메서드가 반환할 객체의 타입이다.
◦
FileStore fs = Files.getFileStore(path);
•
newType : newInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩터리 메서드를 정의할 때 쓴다. “Type”은 펙터리 메서드가 반환할 객체의 타입이다.
◦
BufferReader br = Files.newBufferdReader(path);
•
type : getType과 newType의 간결한 버전
◦
List<Complaint> litany = Collections.list(legacyLitany);
생성자에 매개변수가 많다면 빌더를 고려하라
•
생성자는 선택적 매개변수가 많을 때 적절히 대응하기 어렵다.
•
생성자/정적 팩터리가 처리해야할 매개변수가 많을 때 사용할 수 있는 패턴
◦
점층적 생성자 패턴 사용 → 여러 매개변수에 대응하는 생성자 가능
▪
하지만, 사용자가 설정하길 원치 않는 매개변수까지 포함한다. → 어쩔 수 없이 해당 매개변수에도 값을 지정해줘야 한다.
▪
확장하기 어렵다.
◦
자바빈즈 패턴 사용 → 매개변수가 없는 생성자로 객체를 만들고, setter 메서드들을 호출해 원하는 매개변수의 값을 설정
▪
객체 하나를 만드려면 메서드를 여러개 호출해야한다.
▪
객체가 완전히 생성되기 전까지는 일관성이 무너진 상태에 놓이게 된다.
▪
클래스를 불변으로 만들 수 없다.
▪
스레드 안전성을 얻으려면 추가 작업을 해주어야 한다.
◦
빌더 패턴 사용
▪
점층적 생성자 패턴의 안전성 + 자바 빈즈 패턴의 가독성
▪
쓰기 쉽고, 읽기 쉬운 코드를 가질 수 있다.
▪
명명된 선택적 매개변수를 흉내낸 것이다. (파이썬, 스칼라)
빌더 패턴 (Builder Pattern)
•
필요한 객체를 직접 만드는 대신, 필수 매개변수 만으로 생성자(혹은 정적 팩터리)를 호출해 빌더 객체를 얻는다.
•
나머지 선택 매개변수들은 세터 메서드로 설정한다.
•
마지막으로, 매개변수가 없는 build 메서드를 호출해 불변 객체를 얻는다.
public class NutritionFacts {
private final int servingSize;
private final int servings;
private final int calories;
private final int fat;
private final int sodium;
private final int carbohydrate;
public static class Builder {
// 필수 매개변수
private final int servingSize;
private final int servings;
// 선택 매개변수 - 기본값으로 초기화한다.
private int calories = 0;
private int fat = 0;
private int sodium = 0;
private int carbohydrate = 0;
public Builder(int servingSize, int servings) {
this.servingSize = servingSize;
this.servings = servings;
}
public Builder calories(int val)
{ calories = val; return this; }
public Builder fat(int val)
{ fat = val; return this; }
public Builder sodium(int val)
{ sodium = val; return this; }
public Builder carbohydrate(int val)
{ carbohydrate = val; return this; }
public NutritionFacts build() {
return new NutritionFacts(this);
}
}
private NutritionFacts(Builder builder) {
servingSize = builder.servingSize;
servings = builder.servings;
calories = builder.calories;
fat = builder.fat;
sodium = builder.sodium;
carbohydrate = builder.carbohydrate;
}
public static void main(String[] args) {
NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)
.calories(100)
.sodium(35)
.carbohydrate(27)
.build();
}
}
Java
복사
Private 생성자나 열거타입으로 싱글턴임을 보증하라
•
싱글턴이란?
◦
인스턴스를 오직 하나만 생성할 수 있는 클래스
◦
클래스를 싱글턴으로 만들면 이를 사용하는 클라이언트를 테스트하기 어려워질 수 있다.
•
싱글턴을 만드는 방식
◦
public static 멤버가 final 필드인 방식
▪
해당 클래스가 싱클턴임이 API에 명백히 드러난다.
▪
간결하다.
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
public void leaveTheBuilding() { ... }
}
Java
복사
◦
정적 팩터리 메서드를 public static 멤버로로 제공하는 방식
▪
API를 바꾸지 않고도 싱글턴이 아니게 변경할 수 있다.
▪
원한다면 정적 팩터리를 제네릭 싱글턴 팩터리로 만들 수 있다.
▪
정적 팩터리의 메서드 참조를 공급자로 사용할 수 있다.
public class Elvis {
private static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
public static Elvis getInstance() { return INSTNACE; }
public void leaveTheBuilding() { ... }
}
Java
복사
◦
열거 타입 방식의 싱글턴 방식 - 바람직한 방식
▪
매우 간결하고, 추가 노력 없이 직렬화 할 수 있다.
▪
아주 복잡한 직렬화 상황이나 리플렉션 공격에서도 제 2의 인스턴스가 생기는 일을 완벽히 막을 수 있다.
▪
단, 만들려는 싱글턴이 Enum 외의 클래스를 상속해야 한다면 이 방법은 사용할 수 없다.
public enum Elvis {
INSTANCE;
public void leaveTheBuilding() { ... }
}
Java
복사
자원을 직접 명시하지 말고 의존 객체 주입을 사용하라
•
클래스가 하나 이상의 자원에 의존하고, 그 자원이 클래스 동작에 영향을 준다면, 정적 유틸리티 클래스나 싱글턴 방식은 적합하지 않다.
의존 객체 주입
•
인스턴스를 생성할 때 생성자에 필요한 자원을 넘겨주는 방식
•
클래스의 유연성, 재사용성, 테스트 용이성을 개선한다.
public class SpellChecker {
private final Lexicon dictionary;
public SpellChecker(Lexicon dictionary) {
this.dictionary = Objects.requireNonNull(dictionary);
}
...
}
Java
복사
불필요한 객체 생성을 피하라
•
똑같은 기능의 객체를 매번 생성하기 보다는 객체 하나를 재사용하는 편이 나을 때가 많다.
◦
생성 비용이 아주 비싼 객체가 반복해서 필요하다면, 캐싱하여 재사용하라.
// 값비싼 객체를 재사용해 성능을 개선한다. (32쪽)
public class RomanNumerals {
// 코드 6-1 성능을 훨씬 더 끌어올릴 수 있다!
static boolean isRomanNumeralSlow(String s) {
return s.matches("^(?=.)M*(C[MD]|D?C{0,3})"
+ "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");
}
// 코드 6-2 값비싼 객체를 재사용해 성능을 개선한다.
private static final Pattern ROMAN = Pattern.compile(
"^(?=.)M*(C[MD]|D?C{0,3})"
+ "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");
static boolean isRomanNumeralFast(String s) {
return ROMAN.matcher(s).matches();
}
public static void main(String[] args) {
int numSets = Integer.parseInt(args[0]);
int numReps = Integer.parseInt(args[1]);
boolean b = false;
for (int i = 0; i < numSets; i++) {
long start = System.nanoTime();
for (int j = 0; j < numReps; j++) {
// 성능 차이를 확인하려면 xxxSlow 메서드를 xxxFast 메서드로 바꿔 실행해보자.
b ^= isRomanNumeralSlow("MCMLXXVI");
}
long end = System.nanoTime();
System.out.println(((end - start) / (1_000. * numReps)) + " μs.");
}
// VM이 최적화하지 못하게 막는 코드
if (!b)
System.out.println();
}
}
Java
복사
•
오토박싱을 주의하라!
◦
박싱된 기본 타입보다는 기본 타입을 사용하고, 의도치 않은 오토 박싱이 숨어들지 않도록 주의하자.
// 코드 6-3 끔찍이 느리다! 객체가 만들어지는 위치를 찾았는가? (34쪽)
public class Sum {
private static long sum() {
Long sum = 0L;
for (long i = 0; i <= Integer.MAX_VALUE; i++)
sum += i;
return sum;
}
public static void main(String[] args) {
int numSets = Integer.parseInt(args[0]);
long x = 0;
for (int i = 0; i < numSets; i++) {
long start = System.nanoTime();
x += sum();
long end = System.nanoTime();
System.out.println((end - start) / 1_000_000. + " ms.");
}
// VM이 최적화하지 못하게 막는 코드
if (x == 42)
System.out.println();
}
}
Java
복사
다쓴 객체 참조를 해제하라
•
자기 메모리를 직접 관리하는 클래스라면 메모리 누수에 주의해야한다.
◦
다 쓴 객체의 경우 참조 해제해라. (null 처리 하는 일은 예외적인 경우여야 한다.)
▪
다 쓴 참조를 해제하는 가장 좋은 방법은 그 참조를 담은 변수를 유효 범위 밖으로 밀어내는 것이다.
// 코드 7-1 메모리 누수가 일어나는 위치는 어디인가? (36쪽)
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
ensureCapacity();
elements[size++] = e;
}
public Object pop() {
if (size == 0)
throw new EmptyStackException();
return elements[--size];
}
/**
* 원소를 위한 공간을 적어도 하나 이상 확보한다.
* 배열 크기를 늘려야 할 때마다 대략 두 배씩 늘린다.
*/
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
// 코드 7-2 제대로 구현한 pop 메서드 (37쪽)
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // 다 쓴 참조 해제
return result;
}
public static void main(String[] args) {
Stack stack = new Stack();
for (String arg : args)
stack.push(arg);
while (true)
System.err.println(stack.pop());
}
}
Java
복사
•
캐시 역시 메모리 누수를 일으키는 주범이다.
•
리스너 혹은 콜백을 신경쓰자.
try-finally보다는 try-with-resources를 사용하라
•
close 메소드를 호출해 직접 닫아줘야 하는 자원이 있다면, 자원이 회수됨을 보장하는 수단으로 try-with-resources를 사용하라
◦
close 메소드를 호출해 직접 닫아줘야 하는 자원의 예
▪
InputStream, OutputStream, java.sql.Connection 등
•
try-finally 사용
◦
복수의 자원일 경우에는 코드가 지저분해진다.
◦
여러 예외가 한꺼번에 발생할 경우 첫번째 예외에 대한 문제 진단이 어려워진다.
// 코드 9-1 try-finally - 더 이상 자원을 회수하는 최선의 방책이 아니다! (47쪽)
static String firstLineOfFile(String path) throws IOException {
BufferedReader br = new BufferedReader(new FileReader(path));
try {
return br.readLine(); // 첫 번째 예외 발생
} finally {
br.close(); // 두 번째 예외 발생
}
} // close()에 대한 예외가 readLin()에 대한 예외를 집어삼킨다.
// 코드 9-2 자원이 둘 이상이면 try-finally 방식은 너무 지저분하다! (47쪽)
static void copy(String src, String dst) throws IOException {
InputStream in = new FileInputStream(src);
try {
OutputStream out = new FileOutputStream(dst);
try {
byte[] buf = new byte[BUFFER_SIZE];
int n;
while ((n = in.read(buf)) >= 0)
out.write(buf, 0, n);
} finally {
out.close();
}
} finally {
in.close();
}
}
Java
복사
try-with-resources 사용
•
코드는 더 짧고 분명해진다.
•
만들어지는 예외정보도 훨씬 유용하다.
◦
실제 문제 진단이 필요한 첫번째 예외 (readLine)만 기록되고, close에서 발생한 예외는 숨겨진다. (스택 추적 내역에 숨겨졌다는 꼬리표를 달고 출력된다.)
// 코드 9-3 try-with-resources - 자원을 회수하는 최선책! (48쪽)
static String firstLineOfFile(String path) throws IOException {
try (BufferedReader br = new BufferedReader(
new FileReader(path))) {
return br.readLine();
}
}
// 코드 9-4 복수의 자원을 처리하는 try-with-resources - 짧고 매혹적이다! (49쪽)
static void copy(String src, String dst) throws IOException {
try (InputStream in = new FileInputStream(src);
OutputStream out = new FileOutputStream(dst)) {
byte[] buf = new byte[BUFFER_SIZE];
int n;
while ((n = in.read(buf)) >= 0)
out.write(buf, 0, n);
}
}
Java
복사
모든 객체의 공통 메서드
Object의 equals, hashCode, toString, clone, finalize 그리고 Comparable.compareTo
equals는 일반 규약을 지켜 재정의하라
•
아래와 같은 경우에는 재정의하지 말자. 그냥 두면 그 클래스의 인스턴스는 오직 자기자신과만 같게 된다.
◦
각 인스턴스가 본질적으로 고유하다.
▪
예) Thread
◦
인스턴스의 ‘논리적 동치성(logical equality)’을 검사할 일이 없다.
◦
상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.
◦
클래스가 private이거나 package-private이고, equals 메소드를 호출할 일이 없다.
@Override
public boolean equals(Object o) {
throw new AssertionError(); // 호출 금지!
}
Java
복사
•
equals를 재정의해야 할 때
◦
객체 식별성이 아닌 논리적 동치성을 확인해야하는 경우 → 주로 값 클래스
◦
값이 같은 지를 알고 싶은 경우
•
Object 명세에 적힌 equals 메소드 규약
◦
equals 메서드는 동치관계(equivalence relation)을 구현하며, 다음을 만족한다.
▪
Obejct에서 말하는 동치관계란?
•
Object에서 말하는 동치관계는 쉽게 말해, 서로 같은 원소들로 이루어진 부분집합으로 나누는 연산을 말하며, 이 부분집합을 동치 클래스라 한다.
•
equals()가 쓸모 있으려면, 모든 원소가 같은 동치류에 속하여 어떤 원소와도 서로 교환할 수 있어야 한다.
◦
X = {a, b, c, a, b, c}
◦
A = {a, a}
◦
B = {b, b}
◦
C = {c, c}
◦
A, B, C가 동치클래스이다.
◦
반사성(reflexivity) : null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true다.
▪
반사성은 단순히 말해 객체는 자기 자신과 같아야 한다는 뜻이다.
•
이 요건을 어긴 클래스를 확인하려면, 해당 클래스의 인스턴스를 컬렉션에 넣고 contains()를 호출하면 인스턴스가 없다고 답할 것이다.
var x = new MyEquals();
var list = List.of(x);
list.contains(x);//List의 contains는 Object의 equals를 사용
Java
복사
◦
대칭성(symmetry) : null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)가 true면 y.equals(x)도 true다.
▪
두 객체는 서로에 대한 동치 여부에 똑같이 답해야한다.
▪
equals 규약을 어기면 그 객체를 사용하는 다른 객체들이 어떻게 반응할 지 알 수 없다.
// 대칭성 위반
public final class CaseInsensitiveString{
private final String s;
public CaseInsensitiveString(String s){
this.s = Obejcts.requireNonNull(s);
}
@Override public boolean equals(Object o){
if(o instanceof CaseInsensitiveString)
return s.equalsIgnoreCase(((CaseInsensitiveString) o).s);
if(o instanceof String) // 한방향으로만 작동한다.
return s.equalsIgnoreCase((String) o);
return false;
// CaseInsensitiveString의 equals는 일반 String을 알고 있다.
// cis.equals(s)는 true를 반환한다.
// 하지만, String의 equals는 CaseInsensitiveString의 존재를 모른다.
// 따라서, s.equals(cis)는 false를 반환한다. -> 대칭성 위반
}
}
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish";
cis.equals(s);// true
s.equals(cis);// false
// 대칭성을 만족하도록 수정
// String에 대한 instanceof 부분을 빼고 구현한다.
@Override
public boolean equals(Object o){
return o instanceof CaseInsensitiveString &&
((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
Java
복사
◦
추이성(transitivity) : null이 아닌 모든 참조 값 x, y, z에 대해, x.equals(y)가 true이고, y.equals(z)도 true면, x.equals(z)도 true다.
▪
상위 클래스에 없는 새로운 필드를 하위 클래스에 추가하는 상황일 경우
•
대칭성 위배 문제
// ColorPoint.java 의 equals
@Override public boolean equals(Object o){
if(!(o instanceof ColorPoint))
return false;
return super.equals(o) && ((ColorPoint) o).color == color;
}
public static void main(){
Point p = new Point(1,2);
ColorPoint cp = new ColorPoint(1,2, Color.RED);
p.equals(cp);// true (Point의 equals로 계산)
cp.equals(p);// false (ColorPoint의 equals로 계산: color 필드 부분에서 false)
}
Java
복사
•
추이성 위배 문제
//ColorPoint.java의 equals
@Override public boolean equals(Obejct o){
if(!(o instanceof Point))
return false;
if(!(o instanceof ColorPoint))
return o.equals(this);
return super.equals(o) && ((ColorPoint) o).color == color;
}
public static void main(){
ColorPoint p1 = new ColorPoint(1,2, Color.RED);
Point p2 = new Point(1,2);
ColorPoint p3 = new ColorPoint(1,2, Color.BLUE);
p1.equals(p2);// true (ColorPoint의 equals 비교 //2번째 if문에서 Point의 equals로 변환)
p2.equals(p3);// true (Point의 equals 비교 // x,y 같으니 true)
p1.equals(p3);// false (ColorPoint의 equals 비교)
}
Java
복사
•
무한 재귀에 빠질 수 있는 문제점 발생
//SmellPoint.java의 equals
@Override public boolean equals(Obejct o){
if(!(o instanceof Point))
return false;
if(!(o instanceof SmellPoint))
return o.equals(this);
return super.equals(o) && ((SmellPoint) o).color == color;
}
public static void main(){
ColorPoint p1 = new ColorPoint(1,2, Color.RED);
SmellPoint p2 = new SmellPoint(1,2);
p1.equals(p2);
// 처음에 ColorPoint의 equals로 비교 : 2번째 if문 때문에 SmellPoint의 equals로 비교// 이후 SmellPoint의 equals로 비교 : 2번째 if문 때문에 ColorPoint의 equals로 비교// 무한 재귀의 상태!
}
Java
복사
•
리스코프 치환원칙 위배
@Override public boolean equals(Object o){
if(o == null || o.getClass() != getClass())
return false;
Point p = (Point) o;
return p.x == x && p.y == y;
}
Java
복사
◦
Point의 하위클래스는 정의상 여전히 Point이기 때문에 어디서든 Point로 활용되어야한다.
◦
리스코프 치환원칙 (Liskov substitution principle)
▪
어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 마찬가지로 중요하다. 따라서 그 타입의 모든 메서드가 하위 타입에서도 똑같이 잘 작동해야한다.
▪
해결방법
•
상속대신 컴포지션을 사용하라
public class ColorPoint{
private final Point point;
private final Color color;
public Point asPoint(){//view 메서드 패턴
return point
}
@Override public boolean equals(Object o){
if(!(o instanceof ColorPoint)){
return false;
}
ColorPoint cp = (ColorPoin) o;
return cp.point.equals(point) && cp.color.equals(color);
}
}
Java
복사
◦
ColorPoint - ColorPoint
▪
ColorPoint의 equals를 이용하여 color 값까지 모두 비교
◦
ColorPoint - Point
▪
ColorPoint를 asPoint() 메소드를 이용해 Point로 바꾸어서, Point의 equlas를 이용해 x,y만 비교
◦
Point - Point
▪
Point의 equals를 이용하여 x,y 값 모두 비교
•
추상클래스의 하위클래스 사용
◦
추상클래스의 하위클래스에서는 equals 규약을 지키면서도 값을 추가할 수 있다.
◦
상위 클래스를 직접 인스턴스로 만드는게 불가능하기 때문에 하위클래스끼리의 비교가 가능해진다.
◦
구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.
◦
일관성(consistency) : null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다.
▪
두 객체가 같다면 (어느 하나 혹은 두 객체 모두가 수정되지 않는한) 앞으로도 영원히 같아야한다.
▪
가변객체
•
비교 시점에 따라 서로 다를 수 있다.
▪
불변객체
•
한 번 다르면 끝까지 달라야한다.
▪
equals는 항시 메모리에 존재하는 객체만을 사용한 결정적(deterministic) 계산만 수행해야한다.
▪
클래스가 불변이든 가변이든, equals의 판단에 신뢰할 수 없는 자원이 끼어들면 안된다.
◦
null-아님 : null이 아닌 모든 참조 값 x에 대해, x.equals(null)은 false다.
▪
명시적 null 검사 - 필요 없다!
@Override
public boolean equals(Object o){
if( o == null){
return false;
}
}
Java
복사
▪
묵시적 null 검사 - 이 쪽이 낫다!
@Override public boolean equals(Obejct o){
if(!(o instanceof MyType)) // instanceof 자체가 타입과 무관하게 null이면 false 반환함.
return false;
MyType mt = (MyType) o;
}
Java
복사
•
양질의 equals 메소드 구현 방법 (단계별 정리)
1.
== 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
2.
instanceof 연산자로 입력이 올바른 타입인지 확인한다.
3.
입력을 올바른 타입으로 형변환한다.
4.
입력 객체와 자기 자신의 대응되는 ‘핵심’필드들이 모두 일치하는지 하나씩 검사한다.
•
float와 double을 제외한 기본 타입 필드는 ==연산자로 비교한다.
•
참조 타입 필드는 각각의 equals 메소드로, float와 double 필드는 각각 정적 메소드인 Float.compare(float, float)와 Double.compare(double, double)로 비교한다 .
•
배열 필드는 원소 각각을 앞서의 지침대로 비교한다. 배열의 모든 원소가 핵심 필드라면 Arrays.equals메소드들 중 하나를 사용하자.
•
null도 정상 값으로 취급하는 참조 타입 필드라면, 정적 메소드인 Objects.equals(Object, Object)로 비교해 NullPointerException 발생을 예방하자.
•
비교하기가 아주 복잡한 필드를 가진 클래스인 경우, 그 필드의 표준형(canonical form)을 저장해둔 후 표준형끼리 비교하면 경제적이다. → 특히 불변 클래스에 제격이다.
•
필드 비교 성능은 equals 성능을 좌우한다.
◦
다를 가능성이 더 크거나, 비교하는 비용이 싼 필드를 먼저 비교
◦
파생필드가 객체 전체의 상태를 대표하는 상황일 경우, 파생 필드를 비교하는 쪽이 더 빠를 수 있다.
•
equals를 다 구현했다면, 세 가지만 자문해보자.
◦
대칭적인가?
◦
추이성이 있는가?
◦
일관적인가?
// 코드 10-6 전형적인 equals 메서드의 예 (64쪽)
public final class PhoneNumber {
private final short areaCode, prefix, lineNum;
public PhoneNumber(int areaCode, int prefix, int lineNum) {
this.areaCode = rangeCheck(areaCode, 999, "지역코드");
this.prefix = rangeCheck(prefix, 999, "프리픽스");
this.lineNum = rangeCheck(lineNum, 9999, "가입자 번호");
}
private static short rangeCheck(int val, int max, String arg) {
if (val < 0 || val > max)
throw new IllegalArgumentException(arg + ": " + val);
return (short) val;
}
@Override public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof PhoneNumber))
return false;
PhoneNumber pn = (PhoneNumber)o;
return pn.lineNum == lineNum && pn.prefix == prefix
&& pn.areaCode == areaCode;
}
// 나머지 코드는 생략 - hashCode 메서드는 꼭 필요하다(아이템 11)!
}
Java
복사
•
equals 구현 시 주의사항
◦
equals를 재정의 할 땐 hashCode도 반드시 재정의하자.
◦
너무 복잡하게 해결하려 들지 말자.
▪
필드들의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있다.
◦
Object 외의 타입을 매개변수로 받는 equals 메소드는 선언하지 말자.
equals를 재정의하려거든 hashCode도 재정의하라
equals를 재정의한 클래스 모두에서 hashCode도 재정의해야한다.
•
그렇지 않으면 hashCode 일반 규약을 어기게 되어 해당 클래스를 HashMap이나 HashSet 같은 컬렉션의 원소로 사용할 때 문제를 일으킬 것이다.
•
hashCode없이는 제대로 동작하지 않는다!
•
Object 명세의 3가지 규약
◦
equals()에 사용되는 정보가 변경되지 않았다면, 애플리케이션이 실행되는 동안 그 객체의 hashCode 메소드는 몇 번을 호출해도 일관되게 항상 같은 값을 반환해야 한다. (단, 애플리케이션을 다시 실행한다면 이 값이 달라져도 상관없다.)
▪
hashCode 재정의를 잘못했을 때, 크게 문제가 되는 조항
▪
논리적으로 같은 객체는 같은 해시코드를 반환해야 한다!
◦
equals(object)가 두 객체를 같다고 판단했다면, 두 객체의 hashCode는 똑같은 값을 반환해야한다.
◦
equals(object)가 두 객체를 다르다고 판단했다면, 두 객체의 hashCode가 서로 다른 값을 반환할 필요는 없다. 단, 다른 객체에 대해서는 다른 값을 반환해야 해시테이블의 성능이 좋아진다.
•
좋은 HashCode 작성 방법
1.
int 변수 result를 선언한 후, 값 c로 초기화한다. 이 때, c는 해당 객체의 첫번째 핵심 필드를 단계 2.a 방식으로 계산한 해시코드이다.
2.
해당 객체의 나머지 핵심필드 f 각각에 대해 다음 작업을 수행한다.
a.
해당 필드의 해시코드 c를 계산한다.
•
기본 타입 필드라면, Type.hashCode(f)를 수행한다. 여기서 Type은 해당 기본 타입의 박싱 클래스다.
•
참조 타입 필드면서 이 클래스의 equals 메소드가 이 필드의 equals를 재귀적으로 호출해 비교한다면, 이 필드의 hashCode를 재귀적으로 호출한다. 계산이 복잡해질 것 같으면, 이 필드의 표준형(canonical representation)을 만들어 그 표준형의 hashCode를 호출한다. 필드의 값이 null이면 0을 사용한다.
•
필드가 배열이라면, 핵심 원소 각각을 별도 필드처럼 다룬다. 이상의 규칙을 재귀적으로 적용해 각 핵심 원소의 해시코드를 계산한 다음, 단계 2.b방식으로 갱신한다. 배열에 핵심 원수가 하나도 없다면 단순히 상수(0을 추천한다.)를 사용한다. 모든 원소가 핵심 원소라면 Arrays.hashCode를 사용한다.
b.
단계 2.a에서 계산한 해시코드 c로 result를 갱신한다. 코드로는 다음과 같다.
•
result = 31 * result + c;
3.
result를 반환한다.
// 코드 11-2 전형적인 hashCode 메소드 (70쪽)
@Override
public int hashCode() {
int result = Short.hashCode(areaCode);
result = 31 * result + Short.hashCode(prefix);
result = 31 * result + Short.hashCode(lineNum);
return result;
}
// 코드 11-3 한 줄짜리 hashCode 메서드 - 성능이 살짝 아쉽다. (71쪽)
@Override public int hashCode() {
return Objects.hash(lineNum, prefix, areaCode);
}
// 해시코드를 지연 초기화하는 hashCode 메서드 - 스레드 안정성까지 고려해야 한다. (71쪽)
private int hashCode; // 자동으로 0으로 초기화된다.
@Override public int hashCode() {
int result = hashCode;
if (result == 0) {
result = Short.hashCode(areaCode);
result = 31 * result + Short.hashCode(prefix);
result = 31 * result + Short.hashCode(lineNum);
hashCode = result;
}
return result;
}
Java
복사
•
hashCode 작성 시 주의사항
◦
성능을 높인답시고 해시코드를 계산할 때 핵심 필드를 생략해서는 안 된다.
◦
hashCode가 반환하는 값의 생성규칙을 API 사용자에게 자세히 공표하지 말자. 그래야 클라이언트가 이 값에 의지하지 않게 되고, 추후에 계산 방식을 바꿀 수도 있다.
Comparable을 구현할 지 고려하라
Comparable.compareTo
•
Comparable 인터페이스를 구현하면, 단순 동치성 비교와 더불어 순서까지 비교할 수 있으며, 제네릭하다.
◦
Arrays.sort(a);와 같이 손쉽게 정렬할 수 있다.
◦
검색, 극단값 계산, 자동 정렬되는 컬렉션 관리 또한 쉽게 할 수 있다.
•
사실상 자바 플랫폼 라이브러리의 모든 값 클래스와 열거타입이 Comparble을 구현했다.
•
알파벳, 숫자, 연대와 같이 순서가 명확한 값 클래스를 작성한다면 반드시 Comparable 인터페이스를 구현하자.
public interface Comparable<T> {
int compareTo(T t);
}
Java
복사
•
compareTo 메소드의 일반 규약
◦
이 객체와 주어진 객체의 순서를 비교한다. 이 객체가 주어진 객체보다 작으면 음의 정수를, 같으면 0ㅇ을, 크면 양의 정수를 반환한다. 이 객체와 비교할 수 없는 타입의 객체가 주어지면 ClassCastException을 던진다.
▪
Comparable을 구현한 클래스는 모든 x, y에 대해 sgn(x.compareTo(y)) == -sgn(y.compareTo(x))여야 한다.
▪
Comparable을 구현한 클래스는 추이성을 보장해야 한다. 즉, (x.compareTo(y) > 0 && y.compareTo(z) > 0)이면 x.compareTo(z) > 0이다.
▪
Comparable을 구현한 클래스는 모든 z에 대해 x.compareTo(y) == 0 이면 sgn(x.compareTo(z)) == sgn(y.compareTo(z))다.
▪
이번 권고가 필수는 아니지만 꼭 지키는게 좋다. (x.compareTo(y) == 0) == (x.equals(y))여야 한다. Comparable을 구현하고 이 권고를 지키지 않는 모든 클래스는 그 사실을 명시해야 한다. 예) 주의 : 이 클래스의 순서는 equals 메소드와 일관되지 않다.
•
compareTo 메소드 작성 요령
◦
Comparable은 타입을 인수로 받는 제네릭 인터페이스이므로 compareTo 메소드의 인수 타입은 컴파일 타임에 정해진다. → 입력 인수의 타입을 확인하거나, 형변환할 필요가 없으며, 인수 타입이 잘못되었다면 컴파일 자체가 되지 않는다.
◦
compareTo 메소드는 각 필드가 동치인지를 비교하는게 아니라 그 순서를 비교한다.
◦
객체 참조 필드를 비교하려면 compareTo 메소드를 재귀적으로 호출한다.
◦
Comparable을 구현하지 않은 필드나 표준이 아닌 순서로 비교해야 한다면, 비교자(Comparator)를 대신 사용한다. 비교자는 직접 만들거나 자바가 제공하는 것중에 골라쓰면 된다.
•
비교자(Comparator)
◦
객체 참조가 하나뿐인 비교자
public final class CaseInsensitiveString
implements Comparable<CaseInsensitiveString> {
private final String s;
// 자바가 제공하는 비교자를 사용해 클래스를 비교한다.
public int compareTo(CaseInsensitiveString cis) {
return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s);
}
}
Java
복사
◦
기본 타입 필드가 여럿일 때의 비교자
▪
compareTo 메소드에서 관계 연산자 <와 >를 사용하는 이전 방식은 추천하지 않는다.
▪
클래스에 핵심 필드가 여러 개라면 어느 것을 먼저 비교하느냐가 중요하다.
•
비교 결과가 0이 아니라면, (순서가 결정되면) 거기서 끝이다. 그 결과를 곧장 반환하자.
•
가장 핵심이 되는 필드가 똑같다면, 똑같지 않은 필드를 찾을 때까지 그다음으로 중요한 필드를 비교해나간다.
public int compareTo(PhoneNumber pn) {
int result = Short.compare(areaCode, pn.areaCode);
if (result == 0) {
result = Short.compare(prefix, pn.prefix);
if (result == 0)
result = Short.compare(lineNum, pn.lineNum);
}
return result;
}
Java
복사
◦
비교자 생성 메소드를 활용한 비교자
private static final Comparator<PhoneNumber> COMPARATOR =
comparingInt((PhoneNumber pn) -> pn.areaCode)
.thenComparingInt(pn -> pn.prefix)
.thenComparingInt(pn -> pn.lineNum);
public int compareTo(PhoneNumber pn) {
return COMPARATOR.compare(this, pn);
}
Java
복사
◦
정적 compare 메소드를 활용한 비교자
static Comparator<Object> hashCodeOrder = new Comparator<>() {
public int compare(Object o1, Object o2) {
return Integer.compare(o1.hashCode(), o2.hashCode());
}
};
Java
복사
◦
비교자 생성 메소드를 활용한 비교자
static Comparator<Object> hashCodeOrder =
Comparator.comparingInt(o -> o.hashCode());
Java
복사
클래스와 인터페이스
클래스와 멤버의 접근 권한을 최소화하라
•
잘 설계된 컴포넌트는 클래스 내부 구현 정보를 외부 컴포넌트로부터 완벽히 숨긴다.
→ 구현과 API를 깔끔하게 분리한다.
→ 오직 API를 통해서만 다른 컴포넌트와 소통하며, 서로의 내부 동작 방식에는 전혀 개의치 않는다.
⇒ 정보 은닉, 캡슐화
◦
정보 은닉의 장점
▪
시스템을 구성하는 컴포넌트들을 서로 독립시켜서 개발, 테스트, 최적화, 적용, 분석, 수정을 개별적으로 할 수 있도록 한다.
▪
시스템 개발 속도를 높인다. 여러 컴포넌트를 병렬로 개발할 수 있다.
▪
시스템 관리 비용을 낮춘다. 각 컴포넌트를 더 빨리 파악하여 디버깅 할 수 있고, 다른 컴포넌트로 교체하는 부담도 적기 때문이다.
▪
정보 은닉 자체가 성능을 높여주지는 않지만, 성능 최적화에 도움을 준다.
•
완성된 시스템을 프로파일링해 최적화할 컴포넌트를 정한 다음, 다른 컴포넌트에 영향을 주지 않고 해당 컴포넌트만 최적화할 수 있기 때문이다.
▪
소프트웨어 재사용성을 높인다.
•
외부에 거의 의존하지 않고 독자적으로 동작할 수 있는 컴포넌트라면, 그 컴포넌트와 함께 개발되지 않은 낯선 환경에서도 유용하게 쓰일 가능성이 크다.
▪
큰 시스템을 제작하는 난이도를 낮춰준다. 시스템 전체가 아직 완성되지 않은 상태에서도 개별 컴포넌트의 동작을 검증할 수 있다.
•
자바에서 정보 은닉의 핵심은 접근 제한자의 활용이다.
◦
모든 클래스와 멤버의 접근성을 가능한 한 좁혀야 한다.
◦
소프트웨어가 올바로 동작하는 한, 항상 가장 낮은 접근 수준을 부여해야한다.
•
멤버(필드, 메소드, 중첩 클래스, 중첩 인터페이스)에 부여할 수 있는 접근 수준은 네 가지다.
◦
private : 멤버를 선언한 톱레벨 클래스에서만 접근할 수 있다.
◦
package-private : 멤버가 소속된 패키지 안의 모든 클래스에서 접근할 수 있다. 접근 제한자를 명시하지 않았을 대 적용되는 패키지 접근 수준이다. (단, 인터페이스의 멤버는 기본적으로 public이다.)
◦
protected : package-private의 접근 범위를 포함하여, 이 멤버를 선언한 클래스의 하위 클래스에서도 접근할 수 있다.
◦
public : 모든 곳에서 접근 할 수 있다.
접근 제한자 활용을 통한 정보 은닉 (접근 권한 최소화)
•
클래스의 공개 API를 세심히 설계한 후, 그 외의 모든 멤버는 private으로 만들자.
•
오직 같은 패키지의 다른 클래스가 접근해야 하는 멤버에 한하여 package-private으로 풀어주자.
•
public 클래스의 인스턴스 필드는 되도록 public이 아니어야한다.
◦
해당 필드와 관련된 모든 것은 불변식을 보장할 수 없게 된다.
◦
public 가변 필드를 갖는 클래스는 일반적으로 thread-safe하지 않다.
•
꼭 필요한 요소로서의 상수라면 public static field로 공개한다.
•
클래스에서 public static final 배열 필드를 두거나, 이 필드를 반환하는 접근자 메소드를 제공해서는 안된다. → 클라이언트에서 배열의 내용을 수정할 수 있게 된다.
// 변경 가능한 배열
public static final Thing[] VALUES = { ... };
Java
복사
◦
해결책
▪
public 배열을 private으로 만들고, public 불변 리스트를 추가한다.
// public 배열을 private으로 만들고, public 불변 리스트를 추가한다.
private static final Thing[] PRIVATE_VALUES = { ... };
public static final List<Thing> VALUES {
Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES)));
}
Java
복사
▪
배열을 private로 만들고, 그 복사본을 반환하는 public 메소드를 추가한다. (방어적 복사)
private static final Thing[] PRIVATE_VALUES = { ... };
public static final Thing[] values() {
return PRIVATE_VALUES.clone();
}
Java
복사
상속보다는 컴포지션을 사용하라
•
메소드 호출과 달리 상속은 캡슐화를 깨뜨린다.
◦
상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다.
◦
상위 클래스는 릴리스마다 내부 구현이 달라진다. → 수정하지 않은 하위클래스가 오동작할 수 있다.
•
하위 클래스가 깨지기 쉬운 이유
◦
자신의 다른 부분을 사용하는 자기 사용(self-use) 여부
public class InstrumentedHashSet<E> extends HashSet<E>{
private int addCount = 0;
@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);
}
}
Java
복사
▪
제대로 작동하지 않는다! 왜?
•
InstrumentedHashSet의 addAll 메소드가 HashSet(super)의 addAll 메소드를 사용해 구현되었기 때문이다.
•
HashSet의 addAll 메소드는 이미 add 메소드를 사용해 구현되어 있다. (이러한 내부 구현 방식은 HashSet 문서에는 쓰여있지 않다.)
•
HashSet의 addAll은 각 원소를 add 메소드를 호출해 추가하는데, 이때 불리는 add는 InstrumentedHashSet에서 재정의 한 메소드이므로, addCount에 중복해서 값이 더해지게 된다.
◦
다음 릴리스에서 상위 클래스에 새로운 메소드가 추가된 경우
◦
클래스를 확장 but 메소드 재정의 대신 새로운 메소드 추가
▪
다음 릴리스에 상위 클래스에 새 메소드가 추가되었는데, 운이 없게도 하위 클래스에 추가한 메소드와 시그니쳐가 같고, 반환 타입이 다르다면? → 컴파일 조차 불가능함
▪
반환 타입 마저 같다면? 앞서의 문제와 똑같이 메소드 재정의한 것과 결과가 동일
컴포지션을 사용함으로써 상속의 문제를 해결하자.
•
기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하자.
•
기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서, 이러한 설계를 컴포지션(composition)이라고 한다.
•
전달(forwarding)
◦
새 클래스의 인스턴스 메소드들은 기존 클래스의 대응하는 메소드를 호출해 그 결과를 반환한다.
◦
새 클래스의 메소드들을 전달 메소드(forwarding method)라고 한다.
•
새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 기존 클래스에 새로운 메소드가 추가되더라도 전혀 영향받지 않는다!
•
아래와 같이 래퍼 클래스/전달클래스
// 코드 18-2 래퍼 클래스 - 상속 대신 컴포지션을 사용했다. (117-118쪽)
// Decorator pattern
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 static void main(String[] args) {
InstrumentedSet<String> s = new InstrumentedSet<>(new HashSet<>());
s.addAll(List.of("틱", "탁탁", "펑"));
System.out.println(s.getAddCount());
}
}
// 코드 18-3 재사용할 수 있는 전달 클래스 (118쪽)
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;
public ForwardingSet(Set<E> s) { this.s = s; }
public void clear() { s.clear(); }
public boolean contains(Object o) { return s.contains(o); }
public boolean isEmpty() { return s.isEmpty(); }
public int size() { return s.size(); }
public Iterator<E> iterator() { return s.iterator(); }
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 removeAll(Collection<?> c)
{ return s.removeAll(c); }
public boolean retainAll(Collection<?> c)
{ return s.retainAll(c); }
public Object[] toArray() { return s.toArray(); }
public <T> T[] toArray(T[] a) { return s.toArray(a); }
@Override public boolean equals(Object o)
{ return s.equals(o); }
@Override public int hashCode() { return s.hashCode(); }
@Override public String toString() { return s.toString(); }
}
Java
복사
•
상속은 반드시 하위 클래스가 상위 클래스의 ‘진짜’ 하위 타입인 상황에서만 쓰여야한다.
◦
클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야 한다.
추상클래스 보다는 인터페이스를 우선하라
•
추상클래스가 정의한 타입을 구현하는 클래스는 반드시 추상클래스의 하위 클래스가 되어야 한다.
◦
자바는 단일 상속만 지원하기 때문에, 새로운 타입을 정의하는데 커다란 제약이 된다.
•
인터페이스가 선언한 메소드를 모두 정의하고 그 일반 규약을 잘 지킨 클래스라면 다른 어떤 클래스를 상속했든 같은 타입으로 취급된다.
•
인터페이스의 장점
◦
기존 클래스에도 손쉽게 새로운 인터페이스를 구현해넣을 수 있다.
◦
인터페이스는 믹스인 정의에 안성맞춤이다.
▪
믹스인이란 클래스가 구현할 수 있는 타입으로, 믹스인을 구현한 클래스에 원래의 ‘주된 타입’ 외에도 특정 선택적 행위를 제공한다고 선언하는 효과를 준다.
▪
추상클래스는 기존 클래스에 덧씌울 수 없기 때문에, 믹스인을 정의할 수 없다.
◦
인터페이스로는 계층구조가 없는 타입 프레임워크를 만들 수 있다.
인터페이스 활용
•
래퍼 클래스와 함께 사용하면 인터페이스는 기능을 향상시키는 안전하고 강력한 수단이 된다.
•
인터페이스의 메서드 중 구현 방법이 명백한 것이 있다면, 그 구현을 디폴트 메소드로 제공할 수 있다.
•
인터페이스와 추상 골격 구현 클래스를 함께 제공하여, 인터페이스와 추상 클래스의 장점을 모두 취할 수 있다. → 템플릿 메소드 패턴
◦
인터페이스로는 타입을 정의, 필요한 경우 디폴트 메소드를 함께 제공
◦
골격 구현 클래스는 나머지 메소드들까지 구현
▪
관례상 인터페이스의 이름이 Interface라면, 그 골격 구현 클래스의 이름은 AbstractInterface로 짓는다.
▪
추상 클래스처럼 구현을 도와주는 동시에, 추상 클래스로 타입을 정의할 때 따라오는 심각한 제약에서는 자유롭다.
•
골격 구현 작성 방법
◦
다른 메소드들의 구현에 사용되는 기반 메소드를 선정한다. → 추상메소드
◦
기반 메소드들을 사용해 직접 구현할 수 있는 메소드들을 모두 디폴트 메소드로 제공한다.
// 코드 20-1 골격 구현을 사용해 완성한 구체 클래스 (133쪽)
public class IntArrays {
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;
}
};
}
}
// 코드 20-2 골격 구현 클래스 (134-135쪽)
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();
}
}
Java
복사
멤버 클래스는 되도록 static으로 만들라
중첩 클래스란? 다른 클래스 안에 정의된 클래스를 말한다.
•
정적 멤버 클래스
◦
바깥 클래스와 함께 쓰일 때만 유용한 public 도우미 클래스로 쓰인다.
•
비정적 멤버 클래스
◦
비정적 멤버 클래스의 인스턴스는 바깥 클래스의 인스턴스와 암묵적으로 연결된다.
◦
비정적 맴버 클래스의 인스턴스 메소드에서 정규화된 this를 사용해 바깥 인스턴스의 메소드를 호출하거나, 바깥 인스턴스의 참조를 가져올 수 있다.
▪
정규화된 this란 클래스명.this 형태로 바깥 클래스의 이름을 명시하는 용법을 말한다.
◦
중첩 클래스의 인스턴스가 바깥 인스턴스와 독립적으로 존재할 수 있다면, 정적 멤버 클래스로 만들어야한다. → 비정적 멤버 클래스는 바깥 인스턴스 없이는 생성할 수 없다.
◦
비정적 멤버 클래스의 인스턴스와 바깥 인스턴스 사이의 관계는 멤버 클래스가 인스턴스화될 때 확립되며, 더 이상 변경할 수 없다.
◦
어댑터를 정의할 때 자주 쓰인다.
▪
어떤 클래스의 인스턴스를 감싸 마치 다른 클래스의 인스턴스처럼 보이게 하는 뷰로 사용하는 것이다.
public class MySet<E> extends AbstractSet<E> {
...
@Override public Iterator<E> iterator() {
return new MyIterator();
}
private class MyIterator implements Iterator<E> {
...
}
}
Java
복사
멤버 클래스에서 바깥 인스턴스에 접근할 일이 없다면, 무조건 static을 붙여서 정적 멤버 클래스로 만들자. static을 생략하면 바깥 인스턴스로의 숨은 외부 참조를 갖게된다.
•
익명 클래스
◦
중첩 클래스가 한 메소드 안에서만 쓰이면서, 그 인스턴스를 생성하는 지점이 단 한 곳이고 해당 타입으로 쓰기에 적합한 클래스나 인터페이스가 이미 있는 경우 사용
•
지역 클래스
◦
가장 드물게 사용
제네릭
로우 타입은 사용하지 말라
•
클래스와 인터페이스 선언에 타입 매개변수(type parameter)가 쓰이면, 이를 제네릭 클래스 혹은 제네릭 인터페이스라고 한다. → 제네릭 타입(Generic Type)
◦
List<E>
•
각각의 제네릭 타입은 일련의 매개변수화 타입(parameterized type)을 정의한다.
◦
List<String> : 원소의 타입이 String인 리스트를 뜻하는 매개변수화 타입이다. 여기서 String이 정규화(formal) 타입 매개변수 E에 해당하는 실제(actual) 타입 매개변수이다.
•
제네릭 타입을 하나 정의하면, 그에 딸린 로우 타입(raw type)도 함께 정의된다.
◦
List<E>의 로우타입은 List이다.
◦
타입 선언에서 제네릭 타입 정보가 전부 지워진 것처럼 동작한다.
•
매개변수화된 컬렉션 타입은 타입 안정성을 확보할 수 있다.
◦
private final Collection stamps = ...; 
▪
complie은 되지만, 런타임에 치명적인 오류가 발생한다.
◦
private final Collection<Stamp> stamps = ...; 
로우 타입은 사용하지 마라
•
제네릭이 안겨주는 안정성과 표현력을 모두 잃게 된다.
•
List같은 로우 타입은 사용해서는 안되나, List<Object>처럼 임의 객체를 허용하는 매개변수화 타입은 괜찮다.
◦
List는 제네릭 타입에서 완전히 발을 뺀 것이고, List<Object>는 모든 타입을 허용한다는 의사를 컴파일러에게 명확히 전달한 것이다.
•
비한정적 와일드 카드 타입을 사용하라.
◦
타입 안전하며 유연하다.
◦
제네릭 타입을 쓰고 싶지만, 실제 타입 매개변수가 무엇인지 신경 쓰고 싶지 않을 때
◦
static int numElementsInCommon(Set<?> s1, Set<?> s2) { ... }
◦
비한정적 와일드 카드 타입인 Set<?>와 로우 타입 Set의 차이는 무엇일까?
▪
와일드 타입은 안전하고, 로우타입은 안전하지 않다.
▪
로우 타입 컬렉션에는 아무 원소나 넣을 수 있으니 타입 불변식을 훼손하기 쉽다.
▪
Collection<?>에는 (null 이외에는) 어떤 원소도 넣을 수 없다.
•
로우 타입을 사용해야 할 때
◦
class 리터럴에는 로우 타입을 써야한다.
▪
List.class, String[].class, int.class는 허용하고, List<String>.class와 List<?>.class는 허용하지 않는다.
◦
런타임에는 제네릭 타입 정보가 지워지므로, instanceof 연산자는 비한정적 와일드카입 이외의 매개변수화 타입에는 적용할 수없다.
◦
로우 타입이든, 비한정적 와일드카드 타입이든 instanceof는 완전히 똑같이 동작한다. → 그러므로 필요없는 <?>는 쓰지말자.
◦
if (o instanceof Set) { Set<?> s = (Set<?>) o; }
배열보다는 리스트를 사용하라
•
배열과 제네릭의 차이
◦
배열은 공변이지만 제네릭은 불공변이다.
▪
Sub가 Super의 하위 타입이라면 배열 Sub[]는 배열 Super[]의 하위 타입이 된다.
▪
타입 불일치시 런타임에 실패(배열) vs 컴파일할 때 실패(제네릭)
◦
배열은 실체화 된다.
▪
배열은 런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인한다.
•
런타임에는 type-safe하다.
▪
제네릭은 타입 정보가 런타임에는 소거된다.
•
런타임에는 type-safe하지 않지만, 컴파일 시 type check가 가능하다.
◦
배열은 제네릭 타입, 매개변수화 타입, 타입 매개변수로 사용할 수 없다.
◦
제네릭은 type-safe하다. (컴파일 타임에 에러를 잡을 수 있다.)
// 코드 28-6 리스트 기반 Chooser - 타입 안전성 확보! (168쪽)
public class Chooser<T> {
private final List<T> choiceList;
public Chooser(Collection<T> choices) {
choiceList = new ArrayList<>(choices);
}
public T choose() {
Random rnd = ThreadLocalRandom.current();
return choiceList.get(rnd.nextInt(choiceList.size()));
}
Java
복사
열거타입과 애너테이션
int 상수 대신 열거 타입을 사용하라
•
정수 열거 패턴은 상당히 취약하고, 단점이 많다.
◦
타입 안정성을 보장할 방법이 없다.
◦
표현력이 좋지 않다.
◦
동등 연산자로 비교하더라도 컴파일러가 알아채지 못한다.
열거 타입
•
자바의 열거 타입은 완전한 형태의 클래스이다.
•
상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개한다.
•
밖에서 접근할 수 있는 생성자를 제공하지 않으므로, 사실상 final이다.
•
클라이언트가 인스턴스를 직접 생성하거나 확장할 수 없으니, 열거 타입 선언으로 만들어진 인스턴스들은 딱 하나씩만 존재함이 보장된다.
•
컴파일타임 타입 안정성을 제공한다.
•
메소드와 필드를 추가할 수 있다.
•
활용
◦
상수별 메소드 구현을 활용한 열거 타입
▪
상수별로 다르게 동작하는 코드를 구현할 수 있다. 열거 타입에 추상 메소드를 선언하고, 각 상수별 클래스 몸체, 즉 각 상수에서 자신에 맞게 재정의하는 방법이다.
▪
열거 타입 상수끼리 코드를 공유하기 어렵다는 단점이 있다.
// 코드 34-6 상수별 클래스 몸체(class body)와 데이터를 사용한 열거 타입 (215-216쪽)
public enum Operation {
PLUS("+") {
public double apply(double x, double y) { return x + y; }
},
MINUS("-") {
public double apply(double x, double y) { return x - y; }
},
TIMES("*") {
public double apply(double x, double y) { return x * y; }
},
DIVIDE("/") {
public double apply(double x, double y) { return x / y; }
};
private final String symbol;
Operation(String symbol) { this.symbol = symbol; }
@Override public String toString() { return symbol; }
public abstract double apply(double x, double y);
// 코드 34-7 열거 타입용 fromString 메서드 구현하기 (216쪽)
private static final Map<String, Operation> stringToEnum =
Stream.of(values()).collect(
toMap(Object::toString, e -> e));
// 지정한 문자열에 해당하는 Operation을 (존재한다면) 반환한다.
public static Optional<Operation> fromString(String symbol) {
return Optional.ofNullable(stringToEnum.get(symbol));
}
Java
복사
◦
전략 열거 타입 패턴
▪
열거 타입 상수 일부가 같은 동작을 공유하는 경우
// 코드 34-9 전략 열거 타입 패턴 (218-219쪽)
enum PayrollDay {
MONDAY(WEEKDAY), TUESDAY(WEEKDAY), WEDNESDAY(WEEKDAY),
THURSDAY(WEEKDAY), FRIDAY(WEEKDAY),
SATURDAY(WEEKEND), SUNDAY(WEEKEND);
private final PayType payType;
// 새로운 상수를 추가할 때, 잔업수당 '전략'을 선택하도록 한다.
PayrollDay(PayType payType) { this.payType = payType; }
int pay(int minutesWorked, int payRate) {
return payType.pay(minutesWorked, payRate);
}
// 전략 열거 타입
// 전략에 따른 잔업 수당 계산 -> 중첩 열거 타입에 위임
enum PayType {
WEEKDAY {
int overtimePay(int minsWorked, int payRate) {
return minsWorked <= MINS_PER_SHIFT ? 0 :
(minsWorked - MINS_PER_SHIFT) * payRate / 2;
}
},
WEEKEND {
int overtimePay(int minsWorked, int payRate) {
return minsWorked * payRate / 2;
}
};
abstract int overtimePay(int mins, int payRate);
private static final int MINS_PER_SHIFT = 8 * 60;
int pay(int minsWorked, int payRate) {
int basePay = minsWorked * payRate;
return basePay + overtimePay(minsWorked, payRate);
}
}
public static void main(String[] args) {
for (PayrollDay day : values())
System.out.printf("%-10s%d%n", day, day.pay(8 * 60, 1));
}
}
Java
복사
필요한 원소를 컴파일 타임에 다 알 수 있는 상수 집합이라면 항상 열거타입을 사용하자.
열거 타입에 정의된 상수 개수가 영원히 고정 불변일 필요는 없다.
ordinal 인덱싱 대신 EnumMap을 사용하라
EnumMap
•
열거 타입을 Key로 사용하는 Map
class Plant {
enum LifeCycle { ANNUAL, PERENNIAL, BIENNIAL }
final String name;
final LifeCycle lifeCycle;
Plant(String name, LifeCycle lifeCycle) {
this.name = name;
this.lifeCycle = lifeCycle;
}
@Override public String toString() {
return name;
}
public static void main(String[] args) {
Plant[] garden = {
new Plant("바질", LifeCycle.ANNUAL),
new Plant("캐러웨이", LifeCycle.BIENNIAL),
new Plant("딜", LifeCycle.ANNUAL),
new Plant("라벤더", LifeCycle.PERENNIAL),
new Plant("파슬리", LifeCycle.BIENNIAL),
new Plant("로즈마리", LifeCycle.PERENNIAL)
};
// 코드 37-1 ordinal()을 배열 인덱스로 사용 - 따라 하지 말 것! (226쪽)
Set<Plant>[] plantsByLifeCycleArr =
(Set<Plant>[]) new Set[Plant.LifeCycle.values().length];
for (int i = 0; i < plantsByLifeCycleArr.length; i++)
plantsByLifeCycleArr[i] = new HashSet<>();
for (Plant p : garden)
plantsByLifeCycleArr[p.lifeCycle.ordinal()].add(p);
// 결과 출력
for (int i = 0; i < plantsByLifeCycleArr.length; i++) {
System.out.printf("%s: %s%n",
Plant.LifeCycle.values()[i], plantsByLifeCycleArr[i]);
}
// 코드 37-2 EnumMap을 사용해 데이터와 열거 타입을 매핑한다. (227쪽)
Map<Plant.LifeCycle, Set<Plant>> plantsByLifeCycle =
new EnumMap<>(Plant.LifeCycle.class);
for (Plant.LifeCycle lc : Plant.LifeCycle.values())
plantsByLifeCycle.put(lc, new HashSet<>());
for (Plant p : garden)
plantsByLifeCycle.get(p.lifeCycle).add(p);
System.out.println(plantsByLifeCycle);
// 코드 37-3 스트림을 사용한 코드 1 - EnumMap을 사용하지 않는다! (228쪽)
System.out.println(Arrays.stream(garden)
.collect(groupingBy(p -> p.lifeCycle)));
// 코드 37-4 스트림을 사용한 코드 2 - EnumMap을 이용해 데이터와 열거 타입을 매핑했다. (228쪽)
System.out.println(Arrays.stream(garden)
.collect(groupingBy(p -> p.lifeCycle,
() -> new EnumMap<>(LifeCycle.class), toSet())));
}
}
Java
복사
람다와 스트림
익명 클래스 보다는 람다를 사용하라
•
익명 클래스 방식은 코드가 너무 길기 때문에, 자바는 함수형 프로그래밍에 적합하지 않았다.
◦
익명 클래스는 (함수형 인터페이스가 아닌) 타입의 인스턴스를 만들 때만 사용하라.
•
자바 8에 와서, 추상 메소드 하나짜리 인터페이스는 함수형 인터페이스로 이 인터페이스들의 인스턴스를 람다식을 사용해 만들 수 있게 되었다.
•
람다는 함수나 익명 클래스와 개념은 비슷하지만 코드는 훨씬 간결하다.
◦
어떤 동작을 하는지가 명확하게 드러난다.
◦
타입을 명시해야 코드가 더 명확할 때를 제외하고, 람다의 모든 매개변수 타입은 생략하자.
// 함수 객체로 정렬하기 (254-255쪽)
public class SortFourWays {
public static void main(String[] args) {
List<String> words = Arrays.asList(args);
// 코드 42-1 익명 클래스의 인스턴스를 함수 객체로 사용 - 낡은 기법이다! (254쪽)
Collections.sort(words, new Comparator<String>() {
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
}
});
System.out.println(words);
Collections.shuffle(words);
// 코드 42-2 람다식을 함수 객체로 사용 - 익명 클래스 대체 (255쪽)
Collections.sort(words,
(s1, s2) -> Integer.compare(s1.length(), s2.length()));
System.out.println(words);
Collections.shuffle(words);
// 람다 자리에 비교자 생성 메서드(메서드 참조와 함께)를 사용 (255쪽)
Collections.sort(words, comparingInt(String::length));
System.out.println(words);
Collections.shuffle(words);
// 비교자 생성 메서드와 List.sort를 사용 (255쪽)
words.sort(comparingInt(String::length));
System.out.println(words);
}
}
Java
복사
•
람다를 이용하면 열거 타입의 인스턴스 필드를 이용하는 방식으로, 상수별로 다르게 동작하는 코드를 쉽게 구현할 수 있다.
◦
단순히 각 열거 타입 상수의 동작을 람다로 구현해 생성자에 넘기고, 생성자는 이 람다를 인스턴스 필드로 저장해둔다.
◦
그런 다음, apply 메소드에서 필드에 저장된 람다를 호출하기만 하면 된다.
// 코드 42-4 함수 객체(람다)를 인스턴스 필드에 저장해 상수별 동작을 구현한 열거 타입 (256-257쪽)
public enum Operation {
PLUS ("+", (x, y) -> x + y),
MINUS ("-", (x, y) -> x - y),
TIMES ("*", (x, y) -> x * y),
DIVIDE("/", (x, y) -> x / y);
private final String symbol;
private final DoubleBinaryOperator op;
Operation(String symbol, DoubleBinaryOperator op) {
this.symbol = symbol;
this.op = op;
}
@Override public String toString() { return symbol; }
public double apply(double x, double y) {
return op.applyAsDouble(x, y);
}
}
Java
복사
•
람다는 이름이 없고, 문서화도 할 수 없다.
◦
따라서 코드 자체로 동작이 명확히 설명되지 않거나 코드 줄 수가 많아지면 람다를 쓰지 말아야 한다.
람다보다는 메소드 참조를 사용하라
메서드 참조 유형 | 예 | 같은 기능을 하는 람다 |
정적 | Integer::parseInt | str → Integer.parseInt(str) |
한정적 (인스턴스) | Instant.now()::isAfter | Instant then = Instant.now();
t → then.isAfter(t) |
비한정적 (인스턴스) | String::toLowerCase | str → str.toLowerCase() |
클래스 생성자 | Tree<Map<K, V>::new | () → new TreeMap<K, V>() |
배열 생성자 | int[]::new | len → new int[len] |
•
메소드 참조는 람다의 간단명료한 대안이 될 수 있다. 메소드 참조 쪽이 짧고 명확하다면 메소드 참조를 쓰고, 그렇지 않을 때만 람다를 사용하라.
표준 함수형 인터페이스를 사용하라
•
필요한 용도에 맞는 게 있다면, 직접 구현하지 말고 표준 함수형 인터페이스를 활용하라
◦
java.util.function
◦
표준 함수형 인터페이스 대부분은 기본 타입만 제공한다.
▪
기본 함수형 인터페이스에 박싱된 기본 타입을 넣어 사용하지는 말자.
기본 함수형 인터페이스
인터페이스 | 함수 시그니처 | 예 |
UnaryOperator<T> | T apply(T t) | String::toLowerCase |
BinaryOperator<T> | T apply(T t1, T t2) | BigInteger::add |
Predicate<T> | boolean test(T t) | Collection::isEmpty |
Function<T, R> | R apply(T t) | Arrays::asList |
Supplier<T> | T get() | Instant::now |
Consumer<T> | void accept(T t) | System.out::println |
•
전용 함수형 인터페이스
◦
아래를 하나 이상 만족한다면 전용 함수형 인터페이스를 구현해야하는 건 아닌지 고민해보자.
▪
자주 쓰이며, 이름 자체가 용도를 명확히 설명해준다.
▪
반드시 따라야하는 규약이 있다.
▪
유용한 디폴트 메소드를 제공할 수 있다.
◦
직접 만든 함수형 인터페이스에는 항상 @FunctionalInterface 애너테이션을 사용하라.
스트림에서는 부작용 없는 함수를 사용하라
•
계산을 일련의 변환(transformation)으로 재구성하는 것
◦
각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수
▪
순수 함수란? 오직 입력만이 결과에 영향을 주는 함수를 말한다.
▪
다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않는다.
◦
스트림 연산에 건네는 함수 객체는 모두 부작용이 없어야한다.
◦
람다가 상태를 수정해서는 안된다.
// 빈도표 초기화에 스트림을 적절하지 못하게 혹은 적절하게 사용하는 예 (277-279쪽)
public class Freq {
public static void main(String[] args) throws FileNotFoundException {
File file = new File(args[0]);
// 코드 46-1 스트림 패러다임을 이해하지 못한 채 API만 사용했다 - 따라 하지 말 것! (277쪽)
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
words.forEach(word -> {
freq.merge(word.toLowerCase(), 1L, Long::sum);
});
}
// 코드 46-2 스트림을 제대로 활용해 빈도표를 초기화한다. (278쪽)
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
freq = words
.collect(groupingBy(String::toLowerCase, counting()));
}
// 코드 46-3 빈도표에서 가장 흔한 단어 10개를 뽑아내는 파이프라인 (279쪽)
List<String> topTen = freq.keySet().stream()
.sorted(comparing(freq::get).reversed())
.limit(10)
.collect(toList());
}
}
Java
복사
•
ForEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 사용하지 말자.
•
스트림을 올바로 사용하려면 수집기를 잘 알아둬야한다.
◦
toList
◦
toSet
◦
toMap
◦
groupingBy
◦
joining
메서드
매개변수가 유효한지 검사하라
•
오류는 가능한 한 빨리 발생한 곳에서 잡아야한다.
◦
오류를 발생한 즉시 잡지 못하면 해당 오류를 감지하기 어려워지고, 오류 발생 지점을 찾기 어려워진다.
•
메소드의 매개변수 또한 마찬가지이다. 메소드 몸체가 실행되기 전에 매개변수를 확인한다면 잘못된 값이 넘어왔을 때 즉각적이고 깔끔한 방식으로 예외를 던질 수 있다.
•
매개변수 검사를 제대로 하지 못했을 때 발생하는 문제
◦
메소드가 수행되는 중간에 모호한 예외를 던지며 실패할 수 있다.
◦
메소드가 잘 수행되지만 잘못된 결과를 반환할 때
◦
메소드는 문제없이 수행됐지만, 어떠한 객체를 이상한 상태로 만들어놓아서 미래의 알 수 없는 시점에 이 메소드와는 관련없는 오류를 낼 때 → 실패 원자성을 어기는 결과 발생
•
public과 protected 메소드는 매개변수 값이 잘못됐을 때 던지는 예외를 문서화해야한다.
◦
@throws 자바독 태그를 사용하면 된다.
◦
보통은 IllegalArgumentException, IndexOutOfBoundsException, NullPointerException 중 하나가 될 것이다.
◦
매개변수의 제약을 문서화한다면, 그 제약을 어겼을 때 발생하는 예외도 함께 기술해야한다.
/**
* (현재 값 mod m) 값을 반환한다. 이 메서드는
* 항상 음이 아닌 BigInteger를 반환한다는 점에서 remainder 메서드와 다르다.
*
* @param m 계수(양수여야 한다.)
* @return 현재 값 mod m
* @throws ArithmeticException m이 0보다 작거나 같으면 발생한다.
*/
public BigInteger mod(BigInteger m) {
if (m.signum() <= 0)
throw new ArithmeticException("계수(m)은 양수여야 합니다. " + m);
... // 계산 수행
}
Java
복사
◦
모든 public 메서드에 적용되는 예외인 경우, 클래스 레벨 주석을 기술하는 것이 훨씬 깔끔한 방법이다.
◦
위의 예제에서는 매개변수가 null인 경우 발생하는 NullPointerException에 대한 설명은 BigInteger 클래스 레벨에 기술되어 있기 때문에 위 메소드에는 해당 내용이 기술되어있지 않다.
•
requireNonNull 메소드를 통한 Null 검사 (Java7~)
◦
java.util.Objects.requireNonNull
◦
null 검사를 수동으로 하지 않아도 된다.
◦
원하는 예외메세지도 지정 가능하다.
◦
입력을 그대로 반환하므로 값을 사용하는 동시에 null 검사를 수행할 수 있다.
this.strategy = Objects.requireNonNull(strategy, "전략");
Java
복사
•
메소드 실행 전 매개변수 검사 예외 케이스
◦
유혀성 검사의 비용이 지나치게 높거나, 실용적이지 않을 때
◦
계산 관정에서 암묵적으로 검사가 수행되는 경우
▪
Collection.sort(List)는 두 원소를 비교할 수 있는 타입인지 정렬 과정에서 비교한다. 비교할 수 없는 타입이라면 ClassCastException이 발생한다.
▪
정렬에 앞서 원소들이 상호 비교될 수 있는 타입인지 검사하는 유효성 검사는 실익이 없다.
적시에 방어적 복사본을 만들라
•
클라이언트가 불변식을 깨뜨리려 혈안이 되어 있다고 가정하고 방어적으로 프로그래밍 해야 한다.
◦
클래스의 외부에서 내부를 수정하는 것을 불가능하게 하라.
•
불변식을 지키지 못한 예
// 요구사항 : 기간을 표현하는 다음 클래스는 한번 값이 정해지면 변하지 않도록 할 생각이었다.
// 코드 50-1 기간을 표현하는 클래스 - 불변식을 지키지 못했다. (302-305쪽)
public final class Period {
private final Date start;
private final Date end;
/**
* @param start 시작 시각
* @param end 종료 시각. 시작 시각보다 뒤여야 한다.
* @throws IllegalArgumentException 시작 시각이 종료 시각보다 늦을 때 발생한다.
* @throws NullPointerException start나 end가 null이면 발생한다.
*/
public Period(Date start, Date end) {
if (start.compareTo(end) > 0)
throw new IllegalArgumentException(
start + "가 " + end + "보다 늦다.");
this.start = start;
this.end = end;
}
public Date start() {
return start;
}
public Date end() {
return end;
}
public String toString() {
return start + " - " + end;
}
}
Java
복사
◦
Date가 가변이기 때문에 Period 인스턴스의 내부를 공격할 수 있다.
Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
end.setYear(78); // p의 내부를 수정했다!!
Java
복사
◦
이러한 문제는 Java8 이후로는 쉽게 해결할 수 있다.
▪
Date 대신 불변인 Instant를 사용하면 된다. (혹은 LocalDateTime이나, ZonedDateTime을 사용해도 된다.)
▪
Date는 낡은 API이니 새로운 코드를 잘성할 때는 더이상 사용하면 안된다.
•
외부 공격으로부터 인스턴스의 내부를 보호하려면, 생성자에서 받은 가변 매개변수 각각을 방어적으로 복사해야한다.
◦
Period 인스턴스 안에서는 원본이 아닌 복사본을 사용한다.
// 코드 50-3 수정한 생성자 - 매개변수의 방어적 복사본을 만든다. (304쪽)
public Period(Date start, Date end) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
if (this.start.compareTo(this.end) > 0)
throw new IllegalArgumentException(
this.start + "가 " + this.end + "보다 늦다.");
}
Java
복사
◦
매개변수의 유효성을 검사하기 전에 방어적 복사본을 만들고, 이 복사본으로 유효성을 검사한 점에 주목하자.
◦
Date.clone() 메소드를 사용하지 않은점을 주목하자.
▪
Date는 final이 아니므로 clone이 Date가 정의한 게 아닐 수 있다.
▪
매개변수가 제3자에 의해 확장될 수 있는 타입이라면 방어적 복사본을 만들 때 clone을 사용해서는 안 된다.
◦
접근자 메소드가 내부의 가변 정보를 직접적으로 드러낸다.
▪
접근자가 가변 필드의 방어적 복사본을 반환하도록 한다.
// 코드 50-5 수정한 접근자 - 필드의 방어적 복사본을 반환한다. (305쪽)
public Date start() {
return new Date(start.getTime());
}
public Date end() {
return new Date(end.getTime());
}
Java
복사
•
모든 작업에서 되도록 불변 객체들을 조합해 객체를 구성해야 방어적 복사를 할 일이 줄어든다.
•
복사 비용이 너무 크거나, 클라이언트가 그 요소를 잘못 수정할 일이 없음을 신뢰한다면 방어적 복사를 수행하는 대신 해당 구성요소를 수정했을 때의 책임이 클라이언트에 있음을 문서에 명시하자.
null이 아닌, 빈 컬렉션이나 배열을 반환하라
옵셔널 반환은 신중하게 하라
일반적 프로그래밍 원칙
... 작성중 @2/7/2022