3단계 계산기 - enum 및 Generic을 활용한 데이터 분류
완성된 계산기 코드를 보면서 얘기를 해보자. 아래는 전체 코드이다.
package calculator;
import java.util.Objects;
public class OperationExecutor<T extends Number> {
private final ResultHistory resultHistory; // ResultHistory 객체를 참조하는 resultHistory 필드
public OperationExecutor(ResultHistory resultHistory) { // ResultHistory 객체를 인자로 받아 resultHistory 필드에 저장
this.resultHistory = resultHistory;
}
public enum Operator {
ADD('+') {
public double apply(Number n1, Number n2) {
return n1.doubleValue() + n2.doubleValue();
}
},
SUBTRACT('-') {
public double apply(Number n1, Number n2) {
return n1.doubleValue() - n2.doubleValue();
}
},
MULTIPLY('*') {
public double apply(Number n1, Number n2) {
return n1.doubleValue() * n2.doubleValue();
}
},
DIVIDE('/') {
public double apply(Number n1, Number n2) {
if (n2.doubleValue() == 0.0) {
throw new ArithmeticException("0으로 나눌 수 없습니다.");
}
return n1.doubleValue() / n2.doubleValue();
}
};
private final char symbol; // 각 연산자에 대응되는 문자 기호를 저장
Operator(char symbol) { // 각 enum 값 생성 시 기호를 설정하는 생성자
this.symbol = symbol;
}
public abstract double apply(Number n1, Number n2); // 각 연산자마다 구현해야 하는 추상 메서드. 두 정수를 받아 계산한 결과를 반환
public static Operator fromSymbol(char symbol) { // 연산자 기호를 받아서 해당하는 Operator enum 값을 반환. 일치하는 것이 없으면 예외 처리
for (Operator op : Operator.values()) {
if (op.symbol == symbol) {
return op;
}
}
return null;
}
}
public double execute(T num1, T num2, char operator) { // 두 실수와 연산자 기호를 입력받아 계산을 수행하고 결과를 반환하는 메서드
double result = Objects.requireNonNull(Operator.fromSymbol(operator)).apply(num1, num2); // fromSymbol을 통해 operator(ADD,DIVIDE...)를 구하고, apply()를 호출해 Operator를 실행한 계산 결과를 얻는다.
resultHistory.getResults().add(result); // 계산 결과를 ResultHistory에 저장
return result;
}
}
우선 enum은 열거형 타입으로 아래 코드에서는 사칙연산 기호들을 '고정된 상수 집합'으로 정의해주었다.
ADD, SUBTRACT 등은 각각 하나의 연산자 '유형'이다.
여기서 enum을 활용한 부분만 떼어서 보자.
public class OperationExecutor<T extends Number> {
public enum Operator {
//1.
ADD('+') {
public double apply(Number n1, Number n2) {
return n1.doubleValue() + n2.doubleValue();
}
},
SUBTRACT('-') { ... },
MULTIPLY('*') { ... },
DIVIDE('/') { ... };
//2.
private final char symbol;
//3.
Operator(char symbol) { ... }
//4.
public abstract double apply(Number n1, Number n2);
//5.
public static Operator fromSymbol(char symbol) { ... }
}
}
1.
ADD('+') {
public double apply(Number n1, Number n2) {
return n1.doubleValue() + n2.doubleValue();
}
},
여기서 Number라는 클래스를 상속받아주었다. Number 타입의 변수 n1, n2는 기본 연산자를 '직접' 사용할 수 없다.
추상 메서드는 "무엇을 해야 하는지"만 정의하고, 실제 "어떻게 해야 하는지"는 서브 클래스나 enum 상수 등에서 구현하게 되기 때문이다. Number는 추상 클래스이기때문에 double 기본형으로 변환한 뒤 연산을 해주게끔 변환시켜준 것이다.
ADD 부분을 순서대로 정리를 해보면,
ADD라는 이름의 연산자를 만들고 '+' 라는 기호를 연결해주었다.
그리고 연산을 수행하는 apply() 메서드를 생성해준 것이다.
enum에 추상메서드를 정의하고 연산자 기호마다 각각 오버라이딩해준 방식이다.
2.
private final char symbol;
각 연산자(enum 타입 상수)와 연산기호를 연결하는 필드이다.
final로 설정해주어서 참조 변수가 생성자에서 단 한 번만 초기화되고 그 이후에는 다른 객체를 참조할 수 없도록 해주었다.
이는 실수로 값을 덮어쓰는 걸 방지해주고 참조가 바뀌지 않는다는 것을 명확하게 들어내주는 용도이다.
물론 .add()와 같은 메서드를 호출해서 값은 바꿀 수 있지만 위의 이유로 사용해주었다.
3.
Operator(char symbol) { // 각 enum 값 생성 시 기호를 설정하는 생성자
this.symbol = symbol;
}
상수 선언할 때 전달된 '+', '-' 등을 받아서 필드에 저장해주었다.
4.
public abstract double apply(Number n1, Number n2);
모든 연산자가 구현해야하는 추상 메서드이다.
각각의 연산자가 이 메서드를 오버라이딩해서 동작을 정의한다.
아까 1번에서 구현 apply 메서드이다.
자, 근데 여기서 궁금한 점이 있을 수도 있다.
"아까 1번에서 구현해줬는데, 왜 굳이 추상 메서드를 선언해줘야하지?"
이에 대한 답은 이 추상 메서드 선언은 각 enum 상수에 대해 '반드시' apply() 메서드를 오버라이딩하게 '강제'한다.
이 추상 메서드 선언이 없다면, 자바는 각 enum 상수에서 apply() 메서드를 '꼭' 안써도 되는거로 판단한다.
그래서 실수로 apply()를 안적은 상수가 있어도 에러가 없이 넘어가버려서 나중에 오류가 발생할 수 있다.
사실 코드 안정성과 유지보수의 장점이 크기때문에 선언해주었다고 생각하면된다.
5.
public static Operator fromSymbol(char symbol) { // 연산자 기호를 받아서 해당하는 Operator enum 값을 반환. 일치하는 것이 없으면 예외 처리
for (Operator op : Operator.values()) {
if (op.symbol == symbol) {
return op;
}
}
return null;
}
사용자가 입력한 기호를 받아서 해당 기호와 매칭되는 연산자를 찾아서 반환해주는 메서드이다.
못 찾으면 null을 반환한다. 이는 메인 클래스에서 활용하기 위해 null을 반환해주도록 설계했다.
이번에는 지금까지 하나하나 본 부분을 다시 순서대로 머릿속으로 구현해보자.
1. 먼저 Operator 라는 enum을 정의해준다.
2. 각 연산 기호를 상수로 선언하면서, 그 안에서 연산 기능도 함께 정의해준다.
3. 각 상수에 해당하는 문자 기호도 함께 저장해준다.
4. 문자 기호를 기반으로 어떤 연산자인지 찾아주는 메서드를 만들어주자.
5. 마지막으로, 연산을 강제 구현하게 만드는 추상 메서드를 선언해주자.
정리하자면, "이 enum은 각 연산자를 상수로 정의하면서, 그 안에 해당 연산을 수행하는 메서드를 직접 구현해서 계산기처럼 사용할 수 있도록 만든 구조다!"
자, 여기서 정리를 하다보니 한 가지 궁금한 점이 생길 수도 있다.
그렇다면 분명, 2번에서 각 연산 기호에 맞는 ADD 라던가 SUBTRACT 같은 것을 만들어주었는데,
굳이 3번에서 다시 선언해주어야하나?
해야한다. 왜냐하면, ADD('+')는 내부적으로 기호를 저장한 것 뿐이다.
우리가 enum을 사용할 때는 그냥 Operator.ADD 이런 식으로 '직접' 접근할 수는 있지만, 사용자가 콘솔에서
'+'를 입력했을 때는 그걸 enum으로 바꿔주는 과정이 필요하다.
그 다리 역할을 해주는 것이 symbol 필드와 fromSymbol() 필드인 것이다.
이제 이를 메인 클래스에서 활용한 부분을 보면서 enum은 마무리해보겠다.
// enum 활용한 부분
OperationExecutor.Operator op = OperationExecutor.Operator.fromSymbol(operator);
if (op == null) {
System.out.println("지원하지 않는 연산자입니다. 다시 입력해주세요.");
continue;
}
// 계산 수행
double result = executor.execute(inputNumber1, inputNumber2, operator);
위에 적힌 코드가 너무 길어서 이해하기 힘드니 한 부분씩 떼어서 보자.
OperationExecutor.Operator op = // op는 Operator enum 타입의 변수
OperationExecutor.Operator // Operator가 OperationExecutor 클래스 내의 정의된 enum 타입이라는 것
.fromSymbol(operator); // .fromSymbol은 Operator 내부에 정의된 정적 메서드를 호출
// operator에는 사용자가 입력한 연산자 기호가 담겨있다.
// 이 메서드는 입력 받은 operator 문자를 가지고 해당 연산자에 맞는 enum 값을
// enum 값을 찾아 반환하는 역할 (ex. '=' -> ADD 반환)
사실, 한 부분씩 떼어서 보면 별거 없죠?
하지만, 다른 클래스에서 만든 메서드(기능)을 메인 클래스로 가져와 활용을 하기위해서는
메서드 호출 방법을 잘 알아놔야하기에 적어놓았다. enum은 끝!
이번에는 제네릭을 사용한 부분을 보자.
1.
public class OperationExecutor<T extends Number>
T는 Number의 자식 클래스여야한다는 제한 조걸을 걸어두고 (Interger, Double 등 숫자 타입) 선언해주었다.
2.
public double execute(T num1, T num2, char operator)
메서드에서 T를 써서 실제로 계산에 사용하는 부분이다.
T 타입의 두 숫자를 받아서 enum에서 처리할 때, .doubleValue()로 바꿔서 연산한다.
이렇게하면 int, double 등 다 받아도 잘 동작한다.
3.
OperationExecutor<Double> executor = new OperationExecutor<>(calc);
여기서 <Double>로 타입을 명시하면, 그 뒤로 이 클래스는 Double 타입만 처리하게 된다.
즉, execute(1.2, 3.4, '+') 이런식으로 사용 가능하다.
연산을 수행하고 그 결과를 반환하는 역할을 하는 메서드 구현
public double execute(T num1, T num2, char operator) {
// 두 실수와 연산자 기호를 입력받아 계산을 수행하고 결과를 반환하는 메서드
double result = Objects.requireNonNull(Operator.fromSymbol(operator)).apply(num1, num2);
// fromSymbol을 통해 operator(ADD, DIVIDE...)를 구하고, apply()를 호출해 Operator를 실행한 계산 결과를 얻는다.
resultHistory.getResults().add(result);
// 계산 결과를 ResultHistory에 저장
return result;
// 계산 결과 반환
}
1. 연산자에 해당하는 Operator를 찾고 계산하는 부분
double result = Objects.requireNonNull(Operator.fromSymbol(operator)).apply(num1, num2);
a. Operator.fromSymbol(operator)
- fromSymbol(operator)는 연산자 기호를 기반으로 해당하는 Operator enum 값을 찾아주는 메서드
- 예를 들어, operator가 +라면 Operator.ADD를 반환
- 이 메서드는 Operator enum 클래스 안에서 각 연산자 기호에 맞는 값을 찾고, 찾지 못하면 null을 반환하는데, 이 null을 처리해야한다.
b. Objects.requireNonNull()
- Objects.requireNonNull()은 null을 처리하는 방법
- 만약 Operator.fromSymbol(operator)가 null을 반환하면 NullPointerException이 발생해. 즉, 잘못된 연산자 기호가 들어왔을 때 예외를 처리하기 위해 사용해. 예외 처리는 메인클래스에서!
- null이 아닌 Operator가 반환되면, 그 값을 계속 사용할 수 있도록 보장
c. .apply(num1, num2)
- Operator enum에 정의된 각 연산자(ADD, SUBTRACT 등)는 apply 메서드를 가지고 있다.
- 이 메서드는 두 숫자(num1, num2)를 받아서 계산 결과를 반환. 예를 들어 ADD는 두 숫자를 더하고, DIVIDE는 나누는 연산을 수행
- apply 메서드는 Operator enum 값에 맞는 계산을 실제로 실행
2. 결과를 기록하는 부분
resultHistory.getResults().add(result);
- 계산한 결과를 resultHistory 객체의 결과 리스트에 추가
- resultHistory.getResults()는 결과 리스트를 가져오고, add(result)는 계산된 값을 그 리스트에 추가하는 부분
- 즉, 이 코드에서는 계산된 결과가 연산 결과 히스토리에 기록되어 추후 조회가 가능하게 된다.
3. 최종적으로 결과를 반환하는 부분
return result;
- 계산된 결과를 메서드의 반환값으로 반환
- double result로 계산된 값을 execute 메서드가 반환함으로써, 호출한 곳에서 계산 결과를 사용할 수 있게 된다.