이펙티브 자바, 쉽게 정리하기 - item 34. int 상수 대신 열거 타입을 사용하라
int 상수 패턴 (int enum pattern
)
public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
public static final int APPLE_GRANNY_SMITH = 2;
public static final int ORANGE_NAVEL = 0;
public static final int ORANGE_TEMPLE = 1;
public static final int ORANGE_BLOOD = 2;
- 경우의 수가 한정될 때 각 경우를 상수 값으로 치환하여 표현하는 것이다.
int 상수 패턴의 단점
- 타입 안전을 보장할 방법도 없고, 표현력도 좋지 않다.
- 오렌지를 보낼 메서드에 사과를 보내고
==
연산자로 비교해도 정상적인 결과가 나올 것이다.
- 오렌지를 보낼 메서드에 사과를 보내고
- 앞에 전부
ORANGE
,APPLE
등의 접두어를 붙인 이유는 네임스페이스가 없기 때문이다. - 만일 중간에 값 하나가 빠져서 상수 값이 바뀌면 모두 다시 작성해야 한다.
- 문자열로 출력하기 까다롭다.
자바 열거 타입 (enum type
)
public enum Apple { FUJI, PIPPIN, GRANNY_SMITH }
public enum ORANGE { NAVEL, TEMPLE, BLOOD }
열거 타입(enum type
)의 기반 아이디어
- 상수 하나당 인스턴스를 하나씩 만들어
public static final
필드로 공개하는 것이다.- 인스턴스가 통제된다.
- 원소가 하나이면 싱글턴으로 볼 수 있다.
- 컴파일 타입 안정성을 제공한다.
- 이전의
int 상수 패턴
처럼ORANGE
가 갈 곳에APPLE
이 간다면, 명확히 타입 에러가 발생한다.
- 이전의
- 네임스페이스를 제공하여, 이름이 같은 상수도 평화롭게 공존할 수 있다.
APPLE.RED
와ORANGE.RED
는 구분된다.
toString()
이 출력하기에 적합한 문자열을 내어준다.- 열거 타입에는 다양한 메서드나 필드도 추가 가능하다.
- 추가로 임의의 인터페이스도 구현하게 할 수 있다.
Planet
의 예
enum Planet {
MERCURY(3.302e+23, 2.439e6),
VENUS(4.869e+24, 6.052e6),
EARTH(5.975e+24, 6.378e6),
MARS(6.419e+23, 3.393e6),
JUPITER(1.899e+27, 7.149e7),
SATURN(5.685e+26, 6.027e7),
URANUS(8.683e+25, 2.556e7),
NEPTUNE(1.024e+26, 2.477e7);
private final double mass; // 질량 (단위: 킬로그램)
private final double radius; // 반지름 (단위: 미터)
private final double surfaceGravity; // 표면중력 (단위: m / s^2)
// 중력상수 (단위: m^3 / kg s^2)
private static final double G = 6.677300E-11;
// 생성자
Planet(double mass, double radius) {
this.mass = mass;
this.radius = radius;
this.surfaceGravity = G * mass / (radius * radius);
}
public double mass() { return mass; }
public double radius() { return radius; }
public double surfaceGravity() { return surfaceGravity; }
public double surfaceWeight(double mass) {
return mass * surfaceGravity; // F = ma
}
}
- 열거타입 클래스 내부에서 특정 필드에 값을 할당하고 싶다면, 열거 선언 후 바로 생성자를 통해 할당하면 된다.
public static final
로 공개함을 다시한번 상기하자.- 열거 타입은 근본적으로 불변이라 모든 필드는
final
이어야 한다.
enum.values()
@Test
public void useValueOfEnumTest() {
double earthWeight = Double.parseDouble("185");
double mass = earthWeight / Planet.EARTH.surfaceGravity;
for (Planet value : Planet.values()) {
System.out.printf("%s에서의 무게는 %f이다.%n", value, value.surfaceWeight(mass));
}
}
enum.values()
메서드는 해당enum
네임스페이스에 선언된 모든 값을 배열의 형태로 반환한다.- 위의 테스트코드를 통해 간단하게 모든 행성에서의 무게를 출력하는 코드를 만들었다.
MERCURY에서의 무게는 69.912739이다.
VENUS에서의 무게는 167.434436이다.
EARTH에서의 무게는 185.000000이다.
MARS에서의 무게는 70.226739이다.
JUPITER에서의 무게는 467.990696이다.
SATURN에서의 무게는 197.120111이다.
URANUS에서의 무게는 167.398264이다.
NEPTUNE에서의 무게는 210.208751이다.
만약에
Planet
열거 타입의 행성이 하나 줄더라도, 출력하는 줄 수가 하나 줄어들 뿐 그 이상의 큰 변화는 없다.int형 상수 패턴
과 대비되는 장점이다.
열거타입 상수마다 동작이 달라지는 메서드 구성해보기
enum Operation {
PLUS, MINUS, TIMES, DIVIDE;
public double apply(double x, double y) {
switch (this) {
case PLUS -> {
return x+y;
}
case MINUS -> {
return x-y;
}
case TIMES -> {
return x*y;
}
case DIVIDE -> {
return x/y;
}
default -> throw new IllegalStateException();
}
}
}
@Test
public void operationApplyTest() {
double x = 10;
double y = 15;
for (Operation value : Operation.values()) {
System.out.printf("%f %s %f = %f%n", x, value, y, value.apply(x, y));
}
}
- 간단히
switch
문을 활용하여 구성해보았다. - 이 방법엔 단점이 두개 있다.
- 도달할 일 없는
throw
문을 작성해야 한다. - 새로운 상수가 생길 때마다
case
를 추가하는 것을 잊으면 안된다.- 만일 잊게 되면, 런타임 에러를 만나게 될 것이다.
- 도달할 일 없는
열거타입 상수마다 동작이 달라지는 메서드 구성해보기 2: 추상 메서드 이용하기 (switch 단점 개선)
enum Operation {
PLUS {
@Override
public double apply(double x, double y) {
return x+y;
}
},
MINUS {
@Override
public double apply(double x, double y) {
return x-y;
}
},
TIMES {
@Override
public double apply(double x, double y) {
return x*y;
}
},
DIVIDE {
@Override
public double apply(double x, double y) {
return x/y;
}
};
public abstract double apply(double x, double y);
}
@Test
public void operationApplyTest() {
double x = 10;
double y = 15;
for (Operation value : Operation.values()) {
System.out.printf("%f %s %f = %f%n", x, value, y, value.apply(x, y));
}
}
- 상수별 클래스 몸체에
apply
메서드를 재정의하였다. - 위와 같이 추상 클래스를 이용하면,
case
를 이용할 때처럼 무언가 빼먹을 일이 없다.- 실수로 재정의하지 않았다면, 컴파일 오류로 알려준다.
열거타입 상수마다 동작이 달라지는 메서드 구성해보기 3: 생성자 이용 및 공통 메서드 재정의
enum Operation {
PLUS ("+") {
@Override
public double apply(double x, double y) {
return x+y;
}
},
MINUS ("-") {
@Override
public double apply(double x, double y) {
return x-y;
}
},
TIMES ("*") {
@Override
public double apply(double x, double y) {
return x*y;
}
},
DIVIDE ("/") {
@Override
public double apply(double x, double y) {
return x/y;
}
};
private final String symbol;
Operation(String symbol) {
this.symbol = symbol;
}
public abstract double apply(double x, double y);
@Override
public String toString() {
return symbol;
}
}
@Test
public void operationApplyTest() {
double x = 10;
double y = 15;
for (Operation value : Operation.values()) {
System.out.printf("%f %s %f = %f%n", x, value, y, value.apply(x, y));
}
}
- 내부적으로
symbol
이라는 필드를 두어,+
,-
,*
,/
등 알맞은 기호를 저장했다. toString()
을 재정의하여symbol
필드를 반환하도록 만들었다.- 출력 결과는 다음과 같다.
10.000000 + 15.000000 = 25.000000
10.000000 - 15.000000 = -5.000000
10.000000 * 15.000000 = 150.000000
10.000000 / 15.000000 = 0.666667
enum
에서 toString()
구현 이후에 fromString()
고려하기
enum Operation {
PLUS ("+") {
@Override
public double apply(double x, double y) {
return x+y;
}
},
MINUS ("-") {
@Override
public double apply(double x, double y) {
return x-y;
}
},
TIMES ("*") {
@Override
public double apply(double x, double y) {
return x*y;
}
},
DIVIDE ("/") {
@Override
public double apply(double x, double y) {
return x/y;
}
};
private final String symbol;
Operation(String symbol) {
this.symbol = symbol;
}
public abstract double apply(double x, double y);
@Override
public String toString() {
return symbol;
}
public static final Map<String, Operation> stringToEnum = Stream.of(values()).collect(Collectors.toMap(Object::toString, e -> e));
public static Optional<Operation> fromString(String symbol) {
return Optional.ofNullable(stringToEnum.get(symbol));
}
}
@Test
public void fromStringTest() {
double operation = operation("3 * 5");
System.out.println("operation = " + operation);
}
public double operation(String equation) {
String[] s = equation.split(" ");
if(s.length != 3) {
throw new IllegalArgumentException();
}
Optional<Operation> optionalOperation = Operation.fromString(s[1]);
if(optionalOperation.isPresent()) {
return optionalOperation.get().apply(Double.parseDouble(s[0]), Double.parseDouble(s[2]));
}
throw new IllegalArgumentException();
}
fromString()
을 만들어두면 위와 같이 편리하게 다시 문자열을enum
으로 변경할 수 있다.- 아래의
operation
메서드는 이를 응용해본 예제이다.a operation b
형태의 문자열을 받으면, 문자열을 해석하여 결과를double
타입의 숫자로 반환한다.
열거타입 상수끼리 코드 공유해보기
Operation
열거 타입에서는 따로 겹치는 로직이 존재하지 않았다.- 그냥
x
Operation type
y
형식의 계산 결과만 반환했다.
- 그냥
열거타입 상수끼리 코드를 공유하는 예 1: switch case
enum PayrollDay {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY;
private static final int MINS_PER_SHIFT = 8 * 60;
int pay(int minutesWorked, int payRate) {
int basePay = minutesWorked * payRate;
int overtimePay;
switch(this) {
// 주말
case SATURDAY : case SUNDAY :
overtimePay = basePay / 2;
break;
// 주중
default:
overtimePay = minutesWorked <= MINS_PER_SHIFT ? 0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
}
return basePay + overtimePay;
}
}
@Test
public void payrollDayTest() {
int pay1 = PayrollDay.FRIDAY.pay(480, 200);
System.out.println("pay1 = " + pay1);
int pay2 = PayrollDay.FRIDAY.pay(540, 200);
System.out.println("pay2 = " + pay2);
int pay3 = PayrollDay.SUNDAY.pay(480, 200);
System.out.println("pay3 = " + pay3);
}
switch
문을 이용한 형태는 이전과 같이case
를 반드시 같이 추가해주어야 한다.- 동작은 하지만, 관리 관점에서 위험한 포인트가 있는 코드이다.
- 출력 결과
pay1 = 96000
pay2 = 114000
pay3 = 144000
- 생각 가능한 해결책
- 잔업수당을 계산하는 코드를 모든 상수에 중복해서 넣는다.
- 계산 코드를 평일용과 주말용으로 나눈다.
사실 위 두 방법은 모두 가독성이 크게 떨어진다.
열거타입 상수끼리 코드를 공유하는 예 2: 전략 상수 패턴 사용하기
enum PayrollDay {
MONDAY(PayType.WEEKDAY)
, TUESDAY(PayType.WEEKDAY)
, WEDNESDAY(PayType.WEEKDAY)
, THURSDAY(PayType.WEEKDAY)
, FRIDAY(PayType.WEEKDAY)
, SATURDAY(PayType.WEEKEND)
, SUNDAY(PayType.WEEKEND);
private final PayType payType;
PayrollDay(PayType payType) {
this.payType = payType;
}
public int pay(int minutesWorked, int payRate) {
return payType.pay(minutesWorked, payRate);
}
// 전략 열거 타입
enum PayType {
WEEKDAY {
@Override
int overtimePay(int minsWorked, int payRate) {
return minsWorked <= MINS_PER_SHIFT ? 0 : (minsWorked - MINS_PER_SHIFT) * payRate / 2;
}
},
WEEKEND {
@Override
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);
}
}
}
- 새로운 상수를 추가할 때 무조건 잔업수당 전략을 선택해야 한다.
- 이 패턴은
switch
문보다 조금 더 복잡하지만, 더 안전하고 유연하다.
단, 기존 열거 타입에 상수별 동작을 혼합해 넣는다면,
switch
문이 더 좋은 선택이 될 수 있다.
열거 타입을 쓰는 기준
- 필요한 원소를 컴파일타임에 다 알 수 있는 상수 집합이라면 열거 타입을 사용하자.
- ex) 태양계 행성, 한 주의 요일, 체스 말
- ex) 메뉴 아이템, 연산 코드, 명령줄 플래그
단, 열거 타입에 정의된 상수 개수가 영원히 고정 불변일 필요는 없다. 열거 타입은 나중에 상수가 추가되더라도 바이너리 수준에서 호환되도록 설계되었기 때문에 걱정하지 않아도 된다.
핵심 정리
- 열거 타입은 정수 상수보다 뛰어나다.
- 가독성도 더 좋고 안전하고 강력하다.
- 대다수 열거 타입은 명시적 생성자나 메서드 없이 쓰이지만, 각 상수를 특정 데이터와 연결짓거나 상수마다 다르게 동작할 때는 필요하다.
- 이 경우, 보통은 추상 메서드를 선언한 뒤,
switch
문 대신 상수별 메서드 구현이 낫다. - 열거 타입 상수가 같은 코드를 공유한다면, 전략 열거 타입 패턴을 사용하자.
- 이 경우, 보통은 추상 메서드를 선언한 뒤,
반응형
'Java > 이펙티브 자바' 카테고리의 다른 글
이펙티브 자바, 쉽게 정리하기 - item 36. 비트 필드 대신 EnumSet을 사용하라 (0) | 2022.02.24 |
---|---|
이펙티브 자바, 쉽게 정리하기 - item 35. ordinal 메서드 대신 인스턴스 필드를 사용하라 (0) | 2022.02.24 |
이펙티브 자바, 쉽게 정리하기 - item 33. 타입 안전 이종 컨테이너를 고려하라 (0) | 2022.01.24 |
이펙티브 자바, 쉽게 정리하기 - item 32. 제네릭과 가변인수를 함께 쓸 때는 신중하라 (0) | 2022.01.24 |
이펙티브 자바, 쉽게 정리하기 - item 31. 한정적 와일드카드를 사용해 API 유연성을 높이라 (0) | 2022.01.24 |