ecsimsw
자바 깊이 알기 / Enum의 원리와 구현 본문
자바에서의 열거형(enums)
처음 자바를 배웠을 때, annotation와 함께 가장 신기한 개념이었다. 어떻게 동작하는지도 모르고, 그저 사용 방식을 외워 사용해왔던 것 같아, 이번 포스팅을 정리하면서 좀 더 스스로 명확히 할 수 있도록 공부하려고 한다.
더하여 열거형의 조상 java.lang.Enum 타입을 흉내 내 보았다.
타입에 안전한 열거형 (Typesafe enum)
C언어의 Enum은 단순히 이름과 상수를 매핑하는 것에 가깝다. 이를 테면, 아래 C언어에서의 열거형 예시에서 DayOfweek.Tuesday와 Numers.Three 둘 다 상수 2가 할당이 되어있으므로, 논리적으로는 맞지 않으나 C언어에서는 '같다'로 처리된다.
#include <stdio.h>
enum DayOfWeek {
Sunday = 0,
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday
};
enum Numbers {
One = 0,
Two,
Three,
Four,
Five
};
int main()
{
printf("%d\n", Three == Tuesday); // true??
return 0;
}
반면 자바에서는 서로 다른 Enum 타입의 비교는 컴파일 에러를 낳는다.
class Typesafe_자바의Enum{
enum DayOfWeek {
Sunday,Monday,Tuesday,Wednesday,Thursday,Friday,Saturday
}
enum Numbers {
One,Two,Three,Four,Five
}
public static void main(String[] args) {
System.out.println(DayOfWeek.Tuesday == Numbers.Three); // ERROR!!
}
}
별거 아닌 것 같은 차이라고 넘길 수 있지만 개인적으로 Typesafe하다는 점은 굉장히 중요한 포인트라고 생각한다. 사용자의 입력을 열거형을 이용해서 받는다고 할 때를 가정해보자.
class Sample{
enum UserInputButton {
Yes, No
}
public static void main(String[] args) {
UserInputButton input = getUserInput();
if(input == 0){
// Typesafe하지 않는, 0은 매직 넘버
}
if(input == MyErrorCode.IllegalArgumentException){
// Typesafe하지 않는, 비논리적
}
if(input == UserInputButton.Yes){
// Typesafe한
}
}
}
main 함수에서 if(input == 0) 과 같은 꼴은 상수 코드를 매직넘버로 사용한다. Yes와 No 뿐이라 크게 불편함을 느끼지 않겠지만, 대답의 유형이 많아지거나 상수 코드가 자주 바뀔 때 가독성을 해칠 뿐 아니라 유지 보수를 어렵게 만들 것이다.
Typesafe하지 않은 꼴에서는 if(input == MyErrorCode.IllegalArgumentException) 같은 비논리적인 비교를 컴파일 에러로 거르지 않기 때문에 안전하지 못한 코드를 만들기 십상이다.
'Typesafe 하다'의 의미는 더 공부하고, 경험하면서 계속 고민하고, 생각해봐야겠다.
관련된 속성과 행위 묶기
자바의 열거형은 멤버를 가질 수 있다. '관련된 속성과 행위 묶기'라는 소제목을 사용한 이유는 멤버를 갖을 수 있다는 문법 자체보다, '코드 응집을 위한 설계'에 포인트를 주고 싶었기 때문이다.
(단순히 문법을 공부하기보다 사용해야 하는 이유에 집중하고 싶다는 말인데, 위 문장은 작성한 나조차 어렵고 막연한 것 같다. 최대한 쉽고 간단하게 설명해야 하는데..)
아래 코드처럼 Vehicle 열거형을 정의하면서 각 열거형 상수에 속성을 추가할 수 있다. 물론 여러 값을 지정할 수 도 있다.
enum Vehicle{
BUS(1500), AIRPLANE(300000), TAXI(3000);
private int price;
Vehicle(int price){
this.price = price;
}
}
사용하는 이유에 집중하고 싶다고 했다. 이걸 언제 사용할 수 있을까? 왜 사용해야 할까. 내가 생각한 그 이유가 바로 '관련된 것끼리의 응집'이었다.
탈 것에 따라 다른 금액으로, 비용을 계산하는 로직을 짠다고 생각해보자.
enum Vehicle{
BUS, AIRPLANE, TAXI;
}
int calculateAmount(int person, Vehicle vehicleType){
return getFee(vehicleType) * person;
}
int getFee(Vehicle vehicleType){
if(vehicleType == VehicleType.BUS){
return 1500;
}
if(vehicleType == VehicleType.AIRPLANE){
return 300000;
}
if(vehicleType == VehicleType.TAXI){
return 3000;
}
}
이렇게 하면 결국엔 Vehicle 열거형은 열거형대로, 요금 조회와 계산은 또 다른 함수대로 별도의 메소드나 클래스를 필요로 하게 된다. 코드가 길어질 뿐 아니라, 탈 것의 유형이나 금액에 변경이 생길 때마다 메소드가 변경되어야 한다.
Enum에서 멤버를 추가하면 다음처럼 관련된 코드를 묶을 수 있다.
enum Vehicle{
BUS(1500), AIRPLANE(30000), TAXI(30000);
private int fee;
Vehicle(int fee){
this.fee = fee;
}
int calculateAmount(int person){
return fee * person;
}
}
public static void main(String[] args) {
Vehicle.BUS.calculateAmount(3);
}
또는 멤버로 Collection 자료형이나 아래처럼 함수형 인터페이스를 추가할 수 있다.
enum VehicleType{
BUS(1500, ()-> System.out.println("DRIVING")),
AIRPLANE(300000, ()->System.out.println("FLYING")),
TAXI(30000, ()->System.out.println("DRIVING"));
private int fee;
private Runnable go;
VehicleType(int fee, Runnable go){
this.fee = fee;
this.go = go;
}
int calculateAmount(int person){
return fee * person;
}
void run(){
go.run();
}
}
위 코드에서 요금을 계산하는 방식이 person * fee로 각 운송 수단마다 똑같다는 점이 불만족스럽다. 각 수단마다 반드시 요금 계산 방식을 정의하도록 하고 싶을 때는 추상 메소드를 사용할 수 있다.
enum VehicleType {
BUS(1500) {
@Override
int calculateAmount(int person) {
return person * fee;
}
},
AIRPLANE(300000) {
@Override
int calculateAmount(int person) {
int additionalFee = 30000;
return person * fee + additionalFee * person;
}
},
TAXI(30000) {
@Override
int calculateAmount(int person) {
return fee;
}
};
//private int fee;
protected int fee;
VehicleType(int fee) {
this.fee = fee;
}
abstract int calculateAmount(int person);
}
추상 메소드 calculateAmount를 각 열거형 상수마다 정의하도록 강제하여 요금 계산 방식을 정의한다. 이때 fee는 protected이어야 한다.
다음 파트에서는 왜 이런 문법이 적용되었는지, 동작 원리가 뭔지를 설명하겠다.
Enum의 원리
위 Enum 문법이 명확하게 와 닿지 않았다. 그래서 그 동작 원리를 알고 싶었다. 동작 원리를 알면 왜 이런 문법을 사용해야 하는지를 알 수 있을 것 같았다.
일단 열거형의 조상 java.lang.Enum을 흉내내기 앞서, Enum 또한 클래스 문법과 같다는 사실을 생각하고 시작해야 한다. 결국 class의 규칙을 똑같이 따른다. 아래 코드를 보자.
enum Vehicle{
BUS(1500), AIRPLANE(300000), TAXI(3000);
private int price;
Vehicle(int price){
this.price = price;
}
}
열거형 상수부터 이해해야 한다. 사실 각 열거형 상수가 Vehicle 객체이다. 왜 뜬금없이 아래에 생성자를 정의하는지 이해가 되는가. 왜 static final로 정의하는지는 직접 고민해보길 바란다.
abstract class Vehicle extends MyEnum {
public static final Vehicle BUS = new MyVehicleType("BUS", 1500);
public static final Vehicle AIRPLANE = new MyVehicleType("AIRPLANE", 300000);
public static final Vehicle TAXI = new MyVehicleType("TAXI", 30000);
private int fee;
Vehicle(String name, int fee) {
super(name);
this.price = fee;
}
}
그렇다면 왜 abstract 클래스로 Vehicle이 정의되었을까. 추상 메소드를 멤버로 추가하는 상황을 생각해보자.
abstract class Vehicle extends MyEnum {
public static final Vehicle BUS = new Vehicle("BUS", 1500) {
@Override
int calculateAmount(int person) {
return person * fee;
}
};
public static final Vehicle AIRPLANE = new Vehicle("AIRPLANE", 300000) {
@Override
int calculateAmount(int person) {
int additionalFee = 30000;
return person * fee + additionalFee * person;
}
};
public static final Vehicle TAXI = new Vehicle("TAXI", 30000) {
@Override
int calculateAmount(int person) {
return fee;
}
};
protected int fee;
Vehicle(String name, int fee) {
super(name);
this.fee = fee;
}
abstract int calculateAmount(int person);
}
Vehicle을 추상 클래스로 정의하여 calculateAmount를 정의하지 않은 채로 두고, 각 열거형 상수 (Vehicle 객체)는 Vehicle 클래스의 익명 클래스로 인스턴스 되는 것이다.
그래서 앞선 예시 코드에서 추상 클래스를 정의한 enum일 때만 fee를 private으로 둬선 안된다고 언급했던 것이다. fee는 익명 클래스가 생성될 때 calculateAmount를 정의하면서 사용될 것이기 때문에 마치 자식 클래스에 권한을 주는 것처럼 protected로 선언해야 했던 것이다.
이 배경을 알고 보면 앞선 Enum 문법이 조금은 덜 이상하고 신기해 보일 것이라고 생각한다.
enum VehicleType {
BUS(1500) {
@Override
int calculateAmount(int person) {
return person * fee;
}
},
AIRPLANE(300000) {
@Override
int calculateAmount(int person) {
int additionalFee = 30000;
return person * fee + additionalFee * person;
}
},
TAXI(30000) {
@Override
int calculateAmount(int person) {
return fee;
}
};
protected int fee;
VehicleType(int fee) {
this.fee = fee;
}
abstract int calculateAmount(int person);
}
java.lang.Enum 흉내 내기
마지막으로 java.lang.Enum을 흉내내 보려고 한다. enum의 기본적인 메소드 (compareTo(), equals(), valueOf(), values(), ordinal(), name())을 구현해보았다.
abstract class MyEnum<T extends MyEnum> implements Comparable<T>{
//private static int index = 0;
private int ordinal;
private String name;
protected MyEnum(String name, int ordinal){
this.name = name;
this.ordinal = ordinal;
}
protected String name(){
return name;
}
protected int ordinal(){
return ordinal;
}
// XXX :: values()와 valueOf()는 컴파일러가 생성한다.
@Override
public boolean equals(Object object){
return object == this;
}
@Override
public int compareTo(T o) {
if(this.getClass() != o.getClass()){
throw new ClassCastException();
}
return this.ordinal - o.ordinal();
}
}
index(현재 등록된 열거형 상수 개수)와 enumList를 클래스 변수로 두어 상속한 열거형 타입의 상수 개수와 객체들을 공용으로 관리할 수 있도록 하였다. 객체를 저장한 List에서 매개변수로 들어온 이름에 해당하는 객체를 찾아 반환한다. 존재하지 않을 경우 예외를 발생시킨다.
위 코드에서 enum의 values()와 valueOf() 메소드를 잘못 구현했었다가 댓글을 확인하고 정정하였습니다.
enum의 values()와 valueOf()는 컴파일러가 자동 생성합니다.
비교되는 객체가 o를 MyEnum을 상속한 클래스 객체로 제한하기 위해 MyEnum<T extends MyEnum> implements Comparable<T> 꼴로 T를 지정하였다. o.ordinal가 포함됨을 보장할 수 있다.
정리
우아한테크코스 프리코스를 준비하면서 코드의 가독성을 위해 Enum을 사용했다.
사실 이전까지 Enum이 있다는 것을 알았지 왜 써야 하는지, 어떤 점이 좋은지 생각해본 적 없었다. 객체지향 프로그래밍을 고민하면서 느낀 Enum의 사용 이유를 동작 원리와 함께 정리하고 싶었다.
Enum을 직접 구현하는 것이 재밌었다. 아직 흉내 정도지만 왜 Enum 문법이 그렇게 되었나를 생각하면서 반대로 거슬러 올라가 이렇게 짜였지 않았을까 고민하는 시간이 공부가 많이 되었던 것 같다.
'Language > Java, Kotlin' 카테고리의 다른 글
UnmodifiableList은 만능이 아니다. / 방어적 복사 (0) | 2021.02.15 |
---|---|
왜 inner class, Lambda는 effectively final만 접근할 수 있을까. (2) | 2021.02.12 |
자바 깊이 알기 / 자바는 항상 Call by Value. (6) | 2021.01.10 |
자바 깊이 알기 / 자바의 동기화 방식 (1) | 2020.12.24 |
자바 깊이 알기 / Immutable 객체와 메모리 구성 (6) | 2020.11.22 |