(Effective Java 2/E) 136. Item 30 - Use enums instead of int constants

Use enums instead of int constants

  • 2판 제목 : int 상수 대신 enum을 사용하라
  • 3판 제목 : int 상수 대신 열거 타입을 사용하라

1. 열거 자료형이란 무엇인가?

열거 자료형(enumerated type) 은 고정 개수의 상수들로 값이 구성되는 자료형을 뜻 한다.

열거형을 뜻하는 키워드 enum이 자바 1.5에 도입되기 전엔 int를 이용해 상수를 정의할 수 밖에 없었다.

흔히 int enum pattern 으로 알려진 이 방법은 아래와 같은 코드를 말한다.

1
2
3
4
5
6
7
8
// The int enum pattern - severely deficient!
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;

enumvalue를 사용하는 경우라면 별 차이 없지만 타입의 안정성 (type safety) 측면에서 보았을 때 단점이 명백하다.

Orange를 기대하는 메서드에 Apple을 넘기더라도, == 연산자를 통해 Orange와 Apple을 비교해도 컴파일러는 이를 잡아내지 못하기 때문이다.

또한 변수 앞에 APPLE_ORANGE_라는 접두어가 붙어있는데, 이는 상수값들에 대한 별도의 namespace를 구별하기 위해서이다.

이러한 int enum pattern을 사용하는 경우 상수의 특성상 컴파일 타임에 결정되기때문에 상수의 값을 변경하는 경우 재컴파일을 해야한다.

다른 방식으로 int대신 String을 사용하는 String enum pattern이 있는데, 이는 컴파일러가 오타를 감지할 수 없으므로 더욱 안 좋은 방식이라고 볼 수 있다.

상술했듯 자바 1.5부턴 이를 개선한 타입인 enum을 사용할 수 있다.

아래는 간단한 예시이다.

1
2
public enum Apple  { FUJI, PIPPIN, GRANNY_SMITH }
public enum Orange { NAVEL, TEMPLE, BLOOD }

자바의 enum 자료형은 완전한 기능을 갖춘 클래스도 C++나 C#의 enum보다 강력하다는 특징이 있다.

enum 자료형의 기본 아이디어는 열거 상수(enumeration constant) 별로 하나의 객체를 public static final 필드 형태로 제공하는 단순한 방식이다.

따라서 enum 타입은 실질적으로 final로 선언된 것이나 마찬가지로 접근할 수 있는 생성자가 존재하지 않는다.

생성자가 없기에 새로운 객체를 만들수도, 상속을 통해 확장할 수도 없으므로 매우 엄격하게 통제된다고 볼 수 있다.

이런 enum 특유의 엄격함으로 인해 싱글턴을 enum 으로 선언해서 쓰는 방법도 존재하고 있다.

enum 자료형은 컴파일 타임에 이미 타입 안정성을 제공하며 이는 잘못된 값을 전달하는 경우 컴파일러가 오류로 표현한다는 것을 뜻하며 == 연산자를 통해 다른 자료형의 enum 상수들을 비교할 때도 마찬가지이다.

마지막으로 enum 자료형은 별도의 namespace로 분리되기때문에 값의 추가나 순서의 변경이 있더라도 재컴파일할 필요가 없다.

이처럼 enumint enum pattern이 가진 단점을 완전히 해결한 자료형임을 알 수 있다.

물론 기존에 존재하는 단점을 해소하는 데 그치지않고 부가적인 기능을 제공한다.

enum 자료형은 임의의 메서드나 필드를 추가할 수도, 임의의 인터페이스를 구현할 수 도 있으며 그 자체로 객체 취급이기에 당연히 Object 클래스에 정의된 모든 메서드를 제공한다.

추가로 Comparable, Serializable 인터페이스가 구현되어있어 직렬화 이슈도 회피한다.

2. 열거 자료형을 이용한 추상화

enum에 임의의 메서드나 필드를 왜 추가할 수 있다면 뭘 할 수 있을까?

enum은 객체이기에 상수 뿐만 아니라 상수에 데이터를 연계하여 효율적으로 코드를 작성할 수 있다.

이 과정을 통해 enum은 단순한 상수의 모음에서 벗어나 완전한 기능을 갖춘 하나의 추상화 단위(full-featured abstraction) 로 발전시켜나갈 수 있다.

이번엔 태양계의 여덟 행성을 모델링하는 예제를 보자.

각 행성은 질량과 반지름 정보를 가지고 있으며, 이를 통해 행성 표면 중력이나 어떤 물체의 질량이 어떤 무게로 측정될지를 계산할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Enum type with data and behavior
public 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; // In kilograms
private final double radius; // In meters
private final double surfaceGravity; // In m / s^2
// Universal gravitational constant in m^3 / kg s^2
private static final double G = 6.67300E-11;

// Constructor
Planet(double mass, double radius) {
this.mass = mass;
this.radius = radius;
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
}
}

이 예제의 Planet 처럼 다양한 기능을 갖춘 enum을 만들려면 어떻게 해야할까?

enum 상수에 데이터를 넣으려면 해당 객체의 필드를 선언하고, 생성자를 통해 받은 데이터를 저장하면 된다.

물론 enum은 변경이 불가능하므로 모든 필드는 final로 선언해야 한다.

이를 이용해 어떤 물체의 지표면상 무게를 입력받아서 모든 8개 행성 표현에서 측정한 무게를 출력하는 프로그램을 작성할 수 있다.

1
2
3
4
5
6
7
8
9
public class WeightTable {
public static void main(String[] args) {
double earthWeight = Double.parseDouble(args[0]);
double mass = earthWeight / Planet.EARTH.surfaceGravity();
for (Planet p : Planet.values()) {
System.out.printf("Weight on %s is %f%n", p, p.surfaceWeight(mass));
}
}
}

모든 enum 자료형이 그럿듯 Planet의 기본 정의된 values() 메서드를 이용해 모든 상수를 순회할 수 있다.

인자로 175를 넘겼을 때, 실행 결과는 아래와 같다.

1
2
3
4
5
6
7
8
Weight on MERCURY is 66.133672
Weight on VENUS is 158.383926
Weight on EARTH is 175.000000
Weight on MARS is 66.430699
Weight on JUPITER is 442.693902
Weight on SATURN is 186.464970
Weight on URANUS is 158.349709
Weight on NEPTUNE is 198.846116

일반적으로 유용하게 쓰일 enum 이라면 최상위 클래스에 public으로 선언해야 하며, 특정한 최상위 클래스에서만 쓰인다면 멤버 클래스로 선언하는 것이 좋다.

제일 좋은 예시다. java.math.RoundingMode이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package java.math;

/**
* An enum to specify rounding behaviour for numerical operations that may
* discard precision.
* @author Anthony Balkissoon abalkiss at redhat dot com
*
*/
public enum RoundingMode {
UP, DOWN, CEILING, FLOOR, HALF_UP, HALF_DOWN, HALF_EVEN, UNNECESSARY;

/**
* For compatability with Sun's JDK
*/
private static final long serialVersionUID = 432302042773881265L;

/**
* Returns the RoundingMode object corresponding to the legacy rounding modes
* in BigDecimal.
* @param rm the legacy rounding mode
* @return the corresponding RoundingMode
*/
public static RoundingMode valueOf(int rm) {
switch (rm) {
case BigDecimal.ROUND_CEILING:
return CEILING;
case BigDecimal.ROUND_FLOOR:
return FLOOR;
case BigDecimal.ROUND_DOWN:
return DOWN;
case BigDecimal.ROUND_UP:
return UP;
case BigDecimal.ROUND_HALF_UP:
return HALF_UP;
case BigDecimal.ROUND_HALF_DOWN:
return HALF_DOWN;
case BigDecimal.ROUND_HALF_EVEN:
return HALF_EVEN;
case BigDecimal.ROUND_UNNECESSARY:
return UNNECESSARY;
default:
throw new
IllegalArgumentException("invalid argument: " + rm +
". Argument should be one of the " +
"rounding modes defined in BigDecimal.");
}
}
}

RoundingMode는 십진수의 소수점 이하를 어떻게 올림처리할 것인지 표현한다.

RoundingMode는 소스코스에서 보이는 BigDecimal 클래스가 주로 이용하지만, 다른 곳에서도 이용하도록 권장되므로 최상위 enum으로 선언된 것이다.

위의 Planet처럼 모든 상수들이 메서드 내에서 동일하게 동작하는 경우도 있지만, 값에 따라 다른 동작을 해야할 수도 있다.

이때 가장 기본적인 방법은 enum이 표현하는 상수를 이용해 switch 문을 쓰는 것이다.

예를 들어 기본적인 사칙연산을 표현하기 위한 enum은 아래와 같이 작성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Enum type that switches on its own value - questionable
public enum Operation {
PLUS, MINUS, TIMES, DIVIDE;

// Do the arithmetic op represented by this constant
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;
}
throw new AssertionError("Unknown op: " + this);
}
}

3. 상수별 메서드 구현(constant-specific method implementation)

위의 Operation은 동작엔 이상이 없겠지만, 제어 흐름의 범위상 throw가 없으면 실행할 수 없다.

이를 좀 더 개선하면 아래와 같이 작성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Enum type with constant-specific method implementations
public enum Operation {
PLUS {
double apply(double x, double y) {
return x + y;
}
},
MINUS {
double apply(double x, double y) {
return x - y;
}
},
TIMES {
double apply(double x, double y) {
return x * y;
}
},
DIVIDE {
double apply(double x, double y) {
return x / y;
}
};
abstract double apply(double x, double y);
}

위 코드의 abstract 키워드가 쓰인 것을 볼 수 있는데, 이를 이용해 각 상수 별로 실제 메서드를 재정의한다.

이러한 방식을 상수별 메서드 구현(constant-specific method implementation) 이라고 부른다.

이 방법은 만약 새로운 상수를 추가하였을 때 메서드의 구현을 누락하더라도 컴파일러가 알려주므로 놓치지않고 작성할 수 있다.

4. 상수별 데이터(constant-specific data)

상수별 메서드 구현은 상수별 데이터(constant-specific data) 과 혼동될 수도 있다.

예제를 통해 비교해보자.

아래 예제는 toString()을 재정의하여 연산을 나타내는 기호가 반환되도록 구현된 예제이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Enum type with constant-specific class bodies and data
public enum Operation {
PLUS("+") {
double apply(double x, double y) {
return x + y;
}
},
MINUS("-") {
double apply(double x, double y) {
return x - y;
}
},
TIMES("*") {
double apply(double x, double y) {
return x * y;
}
},
DIVIDE("/") {
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; }

abstract double apply(double x, double y);
}

toString()을 재정의한 것은 아래와 같이 활용할 수 있다.

1
2
3
4
5
6
7
public static void main(String[] args) {
double x = Double.parseDouble(args[0]);
double y = Double.parseDouble(args[1]);
for (Operation op : Operation.values()) {
System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
}
}

xy에 각각 2와 4를 넘겼을 때의 출력 결과는 아래와 같다.

1
2
3
4
2.000000 + 4.000000 = 6.000000
2.000000 - 4.000000 = -2.000000
2.000000 * 4.000000 = 8.000000
2.000000 / 4.000000 = 0.500000

5. valueOf()

enum 자료형은 valeOf() 메서드를 자동으로 생성해준다.

valeOf() 메서드를 이용하면 상수의 이름을 상수 그 자체로 변환할 수 있다.

만약 enum에서 toString()을 재정의한 경우 아래와 같이 별도의 메서드를 작성하여 toString()으로 출력하는 값을 다시 enum으로 변환해줄지도 검토해보아야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public enum Operation { 
// ...

// Implementing a fromString method on an enum type
private static final Map<String, Operation> stringToEnum = new HashMap<String, Operation>();

static { // Initialize map from constant name to enum constant
for (Operation op : values()) {
stringToEnum.put(op.toString(), op);
}
}

// Returns Operation for string, or null if string is invalid
public static Operation fromString(String symbol) {
return stringToEnum.get(symbol);
}
}

6. 마무리

본 포스팅에선 enum에 대해서 알아보았다.

enum은 고정된 상수의 집합이 필요할 때 사용할 수 있으며, 단순히 상수 그 이상으로 추상화할 수 있다는 장점이 있다.

추상화가 필요없는 값이라 단순히 int enum pattern을 쓰는 것과 별 차이가 없다하더라도,

enum을 쓰면서 얻을 수 있는 가독성과 타입 안정성 등을 생각하면 안 쓸 이유가 없을 것이다.

동일한 메서드에서 상수별로 다른 동작이 필요한 경우 상수별 메서드 구현을, 여러 상수가 공통 기능을 이용해야하는 경우 정책 enum 패턴을 사용하도록 하자.