6장 들어가기 : 열거 타입과 Annotation
• 자바에는 특수한 목적의 참조 타입이 두가지 있다.
◦ 클래스의 일종인 열거 타입 ( Enum )
◦ 인터페이스의 일종인 Annotation
• 6장에서 이 타입들을 올바르게 사용하는 방법을 알아보자!
정수 열거 패턴 (int enum pattern)
•열거 타입이란?
◦ 일정 개수의 상수 값을 정의한 다음 그 외의 값은 허용하지 않는 타입
◦ 열거 타입으로 만들기 좋은 개념들
- 사계절 (봄-여름-가을-겨울)
- 요일 (월-화-수-목-금-토-일)
- 카드 게임의 카드 종류
•열거 타입이 등장하기 전, 정수 열거 패턴
◦ 자바에서 열거 타입을 지원하기 전에는 아래처럼 정수 상수를 선언해서 사용하곤 했다.
// 정수 열거 패턴(int enum pattern)이라 불리기도 한다.
public static final int MINT_FLAVOR = 3;
public static final int MINT_MILK = 4;
public static final int CHOCOLATE_FLAVOR = 1;
public static final int CHOCOLATE_MILK = 2;
◦ 민트용 상수의 이름은 모두 MINT_ 로 시작하고 초콜릿용 상수는 모두 CHOCOLATE_ 으로 시작한다.
- MINT 도 FLAVOR와 MILK 를 상수 이름으로 사용하고 싶고,
CHCOLATE 도 FLAVOR와 MILK 를 상수 이름으로 사용하고 싶은 경우 위 처럼 접두어를 이용해 충돌을 방지해줘야 한다.
•정수 열거 패턴의 단점
◦ 타입 안전을 보장할 방법이 없으며 표현력도 좋지 않다.
- 예) 초콜렛을 사용해야 할 상황에서 실수로 민트를 사용해도 컴파일러는 아무런 경고를 출력하지 않는다.
// 아 민트초코 싫다고!
int doubleChoco = MINT_FLAVOR + CHOCOLATE_FLAVOR
◦ 정수 열거 패턴을 사용한 프로그램은 깨지기 쉽다.
- 깨지기 쉽다 = 어느 하나를 수정하면 다른 곳에서도 수정을 해줘야 한다.
- 평범한 상수를 나열한 것 뿐이라 컴파일하면 그 값이 클래스 파일에 그대로 새겨진다.
◦ 즉 상수의 값이 변경되면 다시 컴파일해줘야 한다. 그렇지 않으면 프로그램이 수정 전의 값으로 실행됨
◦ 값을 출력하거나 로그를 찍을 때 변수명은 확인할 수 없으니, 단지 숫자로만 출력되는 값의 의미를 파악하기 어렵다.
◦ 같은 정수 열거 패턴 그룹에 총 몇개의 상수가 있는지 알기 위해선 일일이 세어줘야 한다.
• 문자열 열거 패턴 (String Enum Pattern)
◦ 정수 대신 문자열 상수를 사용하는 변형 패턴이다.
◦ 상수의 의미를 출력할 수 있다는 점은 좋지만,
문자열 상수의 이름 대신 문자열 값을 그대로 하드코딩하게 만들기 때문에 좋은 방법이 아니다.
◦ 하드코딩한 문자열에 오타가 있어도 컴파일러는 확인할 길이 없으니 런타임 버그가 생긴다.
◦ 문자열 비교에 따른 성능 저하가 발생한다.
Java 열거 타입(Enum)의 등장
• Java 열거 타입
◦ 자바는 정수/문자열 열거 패턴의 단점을 말끔히 해결해주는 대안으로 열거 타입을 제시했다.
◦ public enum Mint { FLAVOR, MILK } public enum Chocolate { FLAVOR, MILK }
◦ 자바의 열거 타입은 완전한 형태의 클래스이기 때문에 단순히 정수값이기만 한 다른 언어의 열거 타입보다 훨씬 강력하다.
• Java 열거 타입 구현 원리
◦ 열거 타입 자체는 클래스이다.
◦ 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개한다.
◦ 밖에서 접근할 수 있는 생성자를 제공하지 않으므로 사실상 final 이다.
◦ 외부에서 열거 타입 인스턴스를 직접 생성하거나 확장할 수 없으므로, 만들어진 인스턴스는 단 하나만 존재함이 보장된다(싱글톤).
• 열거 타입의 장점
◦ 열거 타입은 컴파일타임 타입 안전성을 제공한다.
- mintTest 메소드의 인수는 Mint의 두가지 값중 하나임이 확실하며, 다른 타입의 값을 넘기려 하면 컴파일 오류가 난다.
◦ 열거 타입에는 각자의 이름공간이 있어서 이름이 같은 상수도 공존할 수 있다.
◦ 새로운 상수를 추가하거나 순서를 바꿔도 다시 컴파일 할 필요가 없다.
- 공개되는 것은 오직 필드의 이름 뿐이라 상수 값이 클라이언트로 컴파일되어 각인되지 않기 때문
◦ .toString() 메소드를 사용하면 상수 이름을 출력할 수 있다.
• 열거 타입의 추가적인 기능과 특징
◦ 열거 타입에는 메서드나 필드를 추가할 수 있고, 열거 타입이 인터페이스를 구현하게 할 수도 있다.
- 언제 열거 타입에서 메서드나 필드를 추가하여 사용할까?
→ 각 상수와 연관된 로직 또는 데이터까지 열거 타입 클래스 안에서 다루고 싶을 때
- 예) Mint ICECREAM과 MILK를 함께 구매 했을때의 가격을 알고 싶다.
- 열거 타입 상수 각각을 특정 데이터와 연결지으려면 생성자에서 데이터를 받아 인스턴스 필드에 저장하면 된다.
◦ 열거 타입은 자신 안에 정의된 상수들의 값을 배열에 담아 반환하는 정적 메서드인 values를 제공한다.
- 예) 민트맛 음식 다 주세요
◦ 열거 타입은 근본적으로 불변이라 모든 필드는 final 이어야 한다.
• 열거 타입과 private/package-private 메서드
◦ 열거 타입을 선언한 클래스 혹은 그 패키지에서만 유용한 기능은 private 메서드 또는 package-private 메서드로 구현하는 것이 좋다.
◦ 이렇게 구현된 기능은 해당 열거 타입 상수를 선언한 클래스/패키지에서만 사용할 수 있다.
◦ 즉 일반 클래스와 마찬가지로 기능을 외부에 노출해야할 합당한 이유가 없다면
private/package-private으로 선언하라 (Item15 : 클래스와 멤버의 접근 권한을 최소화하라)
• 열거 타입과 클래스 레벨
◦ 널리 쓰이는 열거 타입은 top-level 클래스로 만들고, 특정 클래스에서만 사용 되는 열거 타입은 해당 클래스의 멤버 클래스로 만들자.
◦ 이러한 규칙을 지향함으로써 다양한 API가 더 일관된 모습을 갖출 수 있게 되었다!
열거 타입 사용의 확장
• 상수마다 적용되는 비즈니스 로직이 달라져야 하는 상황이 있을수도 있다.
◦ 예) 계산기의 연산 종류를 열거 타입으로 선언하는 경우
• switch-case 문을 사용하는 경우
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;
}
throw new AssertionError("알 수 없는 연산: " + this);
}
}
◦ 동작은 하지만 가독성이 좋지 않을 뿐더러 깨지기 쉬운 코드이다.
- 새로운 상수가 추가되면 새로운 case 문을 추가해야 한다.
- 새로운 case 문을 추가하는것을 깜빡하고 프로그램을 실행시키는 경우, 컴파일은 되지만 새로 추가한 연산을 수행하려 하면
AssertionError가 발생할 것이다.
• 상수별 메서드 구현 방법 : 추상 메서드를 사용하는 경우
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;}};
public abstract double apply(double x, double y);
}
◦ 하나의 추상 메서드를 선언하고, 각 상수마다 자신에 맞게 해당 메서드를 재정의하여 사용하는 방법
◦ 메서드 재정의와 상수 선언이 나란히 붙어 있으니 새로운 상수를 추가할 때 메서드 재정의를 깜빡하기 어려울 뿐더러,
추상 메서드이므로 재정의 하지 않으면 컴파일 오류가 난다.
◦ 상수별 메서드 구현을 상수별 데이터와 결합할 수도 있다.
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);
- toString을 재정의하여 상수값 이름 대신 연산 기호를 반환하도록 만들어 주었다.
- 계산식을 출력하는 것이 훨씬 간편해졌다!
- 단, 열거 타입의 toString 메서드를 재정의하려거든, toString 이 반환하는 문자열을 해당 열거 타입 상수로 변환해주는
fromString 메서드도 함께 제공하는것을 고려해보자.
private static final Map<String, Operation> stringToEnum =
Stream.of(values()).collect(Collectors.toMap(Object::toString, e -> e));
// 지정된 문자열에 해당하는 Operation이 존재하면 반환
public static Optional<Operation> fromString(String symbol) {
return Optional.ofNullable(stringToEnum.get(symbol));
}
◦ 상수별 메서드 구현에서 열거 타입을 다룰 때 주의할 점
- 열거 타입의 static 필드 중 열거 타입 생성자에서 접근할 수 있는 것은 상수 변수 뿐이다.
- 열거 타입 생성자가 실행되는 시점에는 정적 필드들이 아직 초기화되기 전 이기 때문
- 열거 타입 생성자에서 자기 자신 뿐만 아니라 같은 열거 타입 내부의 다른 상수에도 접근할 수 없다.
◦ fromString 을 사용할 때 주의할 점
- fromString 의 반환 타입은 Optional<Operation>이다.
- 주어진 문자열이 가리키는 연산이 존재하지 않을 수 있음을 알리기 위함
◦ 상수별 메서드 구현의 단점
- 열거 타입 상수끼리 코드를 공유하기 어렵다.
- 즉, 동일한 연산을 하지만 모양만 다른 상수가 있다고 할 때, 하나의 상수에서 먼저 정의한 연산을 가져다가 사용할 수 없다.
(동일한 연산을 수행하는 코드를 두 번 작성해 주어야 함)(재사용성 BAD)
enum PayrollDay {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY;
private static final int MINS_PER_SHIFT = 8 * 60; // 하루 8시간
int pay(int minutesWorked, int payRate) {
int basePay = minutesWorked * payRate;
int overtimePay;
switch(this) {
case SATURDAY:
case SUNDAY: // 주말
overtimePay = basePay / 2;
break;
default: // 주중
if (minutesWorked <= MINS_PER_SHIFT) overtimePay = 0;
else overtimePay = (minutesWorked-MINS_PER_SHIFT) * payRate / 2;
}
return basePay + overtimePay;
}
}
- ‘공휴일’ 을 나타내는 상수를 열거 타입에 추가하려면, 해당 값을 처리하는 case 문도 추가해야 한다.
• 전략 열거 타입 패턴을 사용하는 방법
◦ 위 상황처럼 열거 타입 상수 일부가 같은 동작을 공유할 때 사용하기 좋은 방법이다.
◦ 새로운 상수를 추가할 때, 해당 상수가 취할 동작인 전략을 선택할 수 있게 하는 것
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));
}
}
- 휴일근무수당 계산을 private 중첩 열거 타입(PayType)으로 옮기고, PayrollDay 열거 타입의 생성자에서 이중 적당한 것을
선택한다.
- 그러면 PayrollDay 열거타입은 휴일근무수당 계산을 해당 전략 열거 타입에 위임하여 switch 문이나 상수별 메서드 구현이
필요 없게 된다.
- 이 패턴은 switch 문보다 복잡하지만 더 안전하고 유연하다.
◦ switch 문을 잘 활용하는 방법
- switch 문은 열거 타입의 상수별 동작을 구현하는데에는 적합하지 않지만 기존 열거 타입에 상수별 동작을 혼합해 넣을 때,
혹은 가끔 쓰이지만 열거 타입 안에 포함시킬만큼 유용하지는 않은 경우 등에는 좋은 선택이 될 수 있다.
열거 타입을 언제 사용해야 할까?
• 필요한 원소를 컴파일 타임에 다 알 수 있는 상수 집합이라면 열거 타입으로 구현하자.
◦ 예) 태양계 행성, 요일, 체스 말 등
• 허용하는 값들을 컴파일 타임에 이미 알고 있을 때에도 사용할 수 있다.
◦ 예) 메뉴 아이템, 연산 코드, 명령줄 플래그 등
• 열거 타입에 정의된 상수 개수가 고정 불변일 필요는 없다.
◦ 열거 타입은 나중에 상수가 추가되어도 바이너리 수준에서 호환되도록 설계되었기 때문
'Java' 카테고리의 다른 글
[Effective Java] ITEM44 : 표준 함수형 인터페이스를 사용하라 (feat.람다) (0) | 2022.08.06 |
---|---|
[Effective Java] ITEM39 : 명명 패턴보다 Annotation을 사용하라 (0) | 2022.08.06 |
[Effective Java] ITEM29 : 이왕이면 제네릭 타입으로 만들라 (0) | 2022.07.31 |
[Effective Java] ITEM24 : 멤버 클래스는 되도록 static으로 만들라 (0) | 2022.07.31 |
[Effective Java] ITEM19 : 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라 (0) | 2022.07.30 |