아이템 2. 생성자에 매개변수가 많다면 빌더를 고려하라. - ksw6169/effective-java GitHub Wiki
-
정적 팩토리와 생성자는 선택적 매개변수가 많을 때 적절히 대응하기 어렵다.
-
선택적 매개변수가 많을 때 대응하는 방법은 여러 가지가 있는데 대표적으로는 다음과 같다.
- 점층적 생성자 패턴
- 자바빈즈 패턴
- 빌더 패턴
-
필수 매개변수만 받는 생성자, 필수 매개변수 1개와 선택 매개변수 1개를 받는 생성자 ...
의 형태로 선택 매개변수를 전부 다 받는 생성자까지 늘려가는 방식의 패턴이다.
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 NutritionFacts(int servingSize, int servings) {
this(servingSize, servings, 0);
}
public NutritionFacts(int servingSize, int servings, int calories) {
this(servingSize, servings, calories, 0);
}
public NutritionFacts(int servingSize, int servings, int calories, int fat) {
this(servingSize, servings, calories, fat, 0);
}
public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium) {
this(servingSize, servings, calories, fat, sodium, 0);
}
public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium, int carbohydrate) {
this.servingSize = servingSize;
this.servings = servings;
this.calories = calories;
this.fat = fat;
this.sodium = sodium;
this.carbohydrate = carbohydrate;
}
}
-
설정을 원치 않는 매개변수까지 포함하기 쉽고 그런 매개변수에도 값을 지정해줘야 한다.
-
매개변수가 많아질수록 클라이언트 코드를 작성하거나 읽기 어렵다.
- 코드를 읽을 때 매개변수의 개수와 각각의 의미가 무엇인지 헷갈릴 수 있음
- 타입이 같은 매개변수가 연달아 있으면 찾기 어려운 버그로 이어질 수도 있음
- 매개변수가 없는 생성자로 객체를 만든 후 Setter 메서드들을 호출해 원하는 매개변수의 값을 설정하는 패턴이다.
@Setter
public class NutritionFacts {
// 매개변수들은 (기본값이 있다면) 기본값으로 초기화된다.
private int servingSize = -1; // 필수; 기본값 없음
private int servings = -1; // 필수; 기본값 없음
private int calories = 0;
private int fat = 0;
private int sodium = 0;
private int carbohydrate = 0;
public NutritionFacts() { }
}
- 자바빈즈 패턴을 사용하면 코드가 길어지긴 하지만 인스턴스를 만들기 쉽고, 그 결과 더 읽기 쉬운 코드가 된다.
NutritionFacts cocaCola = new NutritionFacts();
cocaCola.setServingSize(240);
cocaCola.setServings(8);
cocaCola.setCalories(100);
cocaCola.setSodium(35);
cocaCola.setCarbohydrate(27);
-
객체를 만들려면 메소드를 여러 개 호출해야 한다.
-
클래스를 불변으로 만들 수 없다.
- 객체가 완전히 생성되기 전까지는 일관성(consistency)이 무너진 상태에 놓이게 된다.
- 점층적 생성자 패턴에서는 매개변수들이 유효한지를 생성자에서만 확인하면 일관성을 유지할 수 있었지만 이 패턴에서는 그렇게 하지 못한다.
- 일관성이 깨진 객체는 버그를 만들기 쉽고 디버깅이 어렵다.
- Thread-safe 하게 만들려면 프로그래머가 추가 작업을 해줘야만 한다.
- 이러한 단점을 완화하고자 생성이 끝난 객체를 수동으로 얼리고(freezing) 얼리기 전에는 사용할 수 없도록 하기도 한다. (하지만 이 방법을 쓴다고 하더라도 객체 사용 전에 프로그래머가
freeze()
를 확실히 호출했는지를 컴파일러가 보증할 방법이 없어서 런타임 오류에 취약하다.
/**
* freeze() 예제 : 생성이 끝난 객체를 얼려서 Setter() 를 호출하지 못하도록 막음
*/
@Getter
public class User {
private Long id;
private String name;
private boolean freeze = false;
public void setId(Long id) {
if (isFreeze())
throw new AssertionError();
this.id = id;
}
public void setName(String name) {
if (isFreeze())
throw new AssertionError();
this.name = name;
}
public void freeze() {
this.freeze = true;
}
}
- 점층적 생성자 패턴의 안전성과 자바빈즈 패턴의 가독성을 겸비한 패턴이다.
- 필요한 객체를 직접 만드는 대신, 필수 매개변수만으로 생성자(혹은 정적 팩토리)를 호출해 빌더 객체를 얻는다.
- 그런 다음 빌더 객체가 제공하는 일종의 Setter 메소드들로 원하는 선택 매개변수들을 설정한다.
- 마지막으로 매개변수가 없는
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 calories) {
this.calories = calories;
return this;
}
public Builder fat(int fat) {
this.fat = fat;
return this;
}
...
public NutritionFacts build() {
return new NutritionFacts(this);
}
}
public NutritionFacts(Builder builder) {
this.servingSize = builder.servingSize;
this.servings = builder.servings;
this.calories = builder.calories;
this.fat = builder.fat;
this.sodium = builder.sodium;
this.carbohydrate = builder.carbohydrate;
}
}
- 빌더 패턴으로 작성한 NutritionFacts 클래스는 불변 클래스로 빌더의 Setter 메소드들은 빌더 자신을 반환하기 때문에 연쇄 호출이 가능하다. 이런 방식을 메소드 호출이 흐르듯 연결된다는 뜻으로 플루언트 API(fluent API) 혹은 메소드 연쇄(method chaining) 라 한다. 이 방식을 사용하면 코드가 읽고 쓰기 쉬워진다.
NutritionFacts cocaCola = new Builder(240, 8)
.calories(100)
.sodium(35)
.carbohydrate(27)
.build();
- 빌더 패턴은 (Python과 Scala에 있는) 명명된 선택적 매개변수(named optional parameters)를 흉내낸 것이다.
# Python의 선택적 인수(kwargs : keyword arguments)
def myFunction(**kwargs):
#printing all values which are passed in as an argument
for key, value in kwargs.items():
print ("%s = %s" %(key, value))
# Calling the Function
myFunction(first_name='Tim', last_name='White', mob=99999999, age='30')
- 잘못된 매개변수를 최대한 일찍 발견하려면 빌더의 생성자와 메소드에서 입력 매개변수를 검사하고,
build()
가 호출하는 생성자에서 여러 매개변수에 걸친 불변식(invariant)을 검사하자. 공격에 대비해 이런 불변식을 보장하려면 빌더로부터 매개변수를 복사한 후 해당 객체 필드들도 검사해야 한다.
public final class Period {
private final Date start;
private final Date end;
public Period(Date start, Date end) {
// '시작일자가 종료일자보다 늦을 수 없다'는 불변식 보장을 위한 구문
if (start.compareTo(end) > 0)
throw new IllegalArgumentException(start + "가 " + end + "보다 늦다.");
this.start = start;
this.end = end;
}
}
Date start = new Date();
Date end = new Date();
Period period = new Period(start, end);
end.setYear(78); // period의 내부를 수정함. 불변식을 깨뜨림
- 아래는 불변을 보장하기 위해 생성자에서 멤버 필드 초기화 시 원본이 아닌 복사본을 사용하는 예제다.
public Period(Date start, Date end) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
if (start.compareTo(end) > 0)
throw new IllegalArgumentException(start + "가 " + end + "보다 늦다.");
}
불변(Immutable 혹은 Immutability)
어떠한 변경도 허용하지 않는다는 뜻으로 주로 변경을 허용하는 가변(mutable) 객체와 구분하는 용도로 쓰인다. 대표적으로 String 객체는 한번 만들어지면 절대 값을 바꿀 수 없는 불변 객체다.
불변식(Invariant)
프로그램이 실행되는 동안, 혹은 정해진 기간 동안 반드시 만족해야 하는 조건을 말한다. 다시 말해 변경을 허용할 수는 있으나 주어진 조건 내에서만 허용한다는 뜻이다. 예컨대 리스트의 크기는 반드시 0 이상이어야 하나 만약 한 순간이라도 음수 값이 된다면 불변식이 깨진 것이다. 따라서 가변 객체에도 불변식은 존재할 수 있으며, 넓게 보면 불변은 불변식의 극단적인 예라 할 수 있다. 개발자는 이러한 불변식을 보장하기 위해 Assertion이나 Class Invariant를 이용한다.
- 빌더 패턴은 계층적으로 설계된 클래스와 함께 쓰기에 좋다.
- 아래 예제는 각 계층의 클래스에 관련 빌더를 멤버로 정의한 예제다. 추상 클래스는 추상 빌더를 구체 클래스(concrete class)는 구체 빌더를 갖게 한다.
public abstract class Pizza {
public enum Topping {
HAM,
MUSHROOM,
ONION,
PEPPER,
SAUSAGE
}
final Set<Topping> toppings;
abstract static class Builder<T extends Builder<T>> {
EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);
public T addTopping(Topping topping) {
toppings.add(topping);
return self(); // 하위 클래스에서 형변환하지 않고도 메소드 연쇄를 지원하기 위한 방법
}
// 각 하위 클래스의 빌더에서 build() 를 통해 구체 클래스를 반환하도록 한다. (*공변 반환 타이핑)
abstract Pizza build();
// 하위 클래스는 이 메소드를 재정의하여 this를 반환해야 한다.
protected abstract T self();
}
Pizza(Builder<?> builder) {
// 불변을 보장하기 위해 복사본을 넣어준다.
this.toppings = builder.toppings.clone();
}
}
public class NyPizza extends Pizza {
public enum Size { SMALL, MEDIUM, LARGE }
private final Size size;
public static class Builder extends Pizza.Builder<Builder> {
private final Size size;
public Builder(Size size) {
this.size = Objects.requireNonNull(size);
}
@Override
public NyPizza build() {
return new NyPizza(this);
}
@Override
protected Builder self() {
return this;
}
}
private NyPizza(Builder builder) {
super(builder);
this.size = builder.size;
}
}
public class Calzone extends Pizza {
private final boolean sauseInside;
public static class Builder extends Pizza.Builder<Builder> {
private boolean sauseInside = false; // 기본값
public Builder sauseInside() {
sauseInside = true;
return this;
}
@Override
public Calzone build() {
return new Calzone(this);
}
@Override
protected Builder self() {
return this;
}
}
private Calzone(Builder builder) {
super(builder);
sauseInside = builder.sauseInside;
}
}
NyPizza pizza = new NyPizza.Builder(NyPizza.Size.SMALL)
.addTopping(Pizza.Topping.SAUSAGE)
.addTopping(Pizza.Topping.ONION)
.build();
Calzone calzone = new Calzone.Builder()
.addTopping(Pizza.Topping.HAM)
.sauseInside()
.build();
공변 반환 타이핑(convariant return typing)
하위 클래스의 메소드가 상위 클래스의 메소드가 정의한 반환 타입이 아닌 그 하위 타입을 반환하는 기능을 공변 반환 타이핑이라 한다.
이 기능을 이용하면 클라이언트가 형변환에 신경쓰지 않고도 빌더를 사용할 수 있다.
빌더 패턴은 상당히 유연하다. 빌더 하나로 여러 객체를 순회하면서 만들 수 있고, 빌더에 넘기는 매개변수에 따라 다른 객체를 만들 수도 있다. 객체마다 부여되는 일련번호와 같은 특정 필드는 빌더가 알아서 채우도록 할 수도 있다.
빌더 패턴에 장점만 있는 것은 아니다. 객체를 만들려면 그에 앞서 빌더부터 만들어야 한다. 빌더 생성 비용이 크지는 않지만 성능에 민감한 상황에서는 문제가 될 수 있다. 또한 점층적 생성자 패턴보다는 코드가 장황해서 매개변수가 4개 이상은 되어야 값어치를 한다. 하지만 API는 시간이 지날수록 매개변수가 많아지는 경향이 있음을 명시하자. 생성자나 정적 팩토리 방식으로 시작했다가 나중에 매개변수가 많아지면 빌더 패턴으로 전환할 수 있지만 이전에 만들어둔 생성자와 정적 팩토리가 아주 도드라져 보일 것이다. 그러니 애초에 빌더로 시작하는 편이 나을 때가 많다.
- Effective Java 3/E