이 글은 내 코드가 그렇게 이상한가요? 책을 읽고 중요하다고 생각한 부분들을 중점으로 정리했습니다.
결론부터 말하면 조건 분기 중첩을 해결하기 위해서는 인터페이스, 전략 패턴, 정책 패턴을 사용한다.
세 가지에 대해서 하나씩 정리해 가보자.
if문을 중첩으로 하면 코드의 가독성이 크게 떨어지는 문제가 있다.
이런 중첩 악마를 퇴치하는 방법 중 하나로 조기 리턴
(early return)이 있다.
조기 리턴
은 조건을 만족하지 않는 경우 곧바로 리턴하는 방법이다.가독성을 낮추는 else 구문
도 조기 리턴으로 해결이 가능하다.
단일 책임 선택의 원칙이란 소프트웨어 시스템이 선택지를 제공해야 한다면, 그 시스템 내부의 어떤 한 모듈만으로 모든 선택지를 파악할 수 있어야 한다. - 객체 지향 소프트웨어 설계 2판 원칙과 개념 -
따라서 조건식이 같은 조건 분기가 있으면, 여러 클래스로 작성하지 말고 하나의 클래스에 작성하자.
그리고 클래스가 거대해지면 관심사에 따라 작은 클래스로 분할하는 것이 중요한데, 이러한 문제를 해결할 때는 인터페이스
를 사용한다.
인터페이스
는 기능 변경을 편리하게 할 수 있는 개념으로, 분기 로직을 작성하지 않고도 분기와 같은 기능을 구현할 수 있다.
인터페이스
에는 다음과 같은 장점들이 있다.
같은 자료형으로 사용할 수 있으므로, 굳이 자료형을 판정하지 않아도 된다.
조건 분기를 따로 작성하지 않고도 각각의 코드를 적절하게 실행할 수 있다.
자료형 판정 분기를 따로 작성하지 않아도 된다.
예를 들어 사각형과 원을 프로그램상에서 같은 도형을 다룰 수 있게 가정한다.
여기서 도형을 나타내는 Shape라는 이름의 인터페이스를 만든다.
그리고 공통 메서드도 정의한다. 도형의 면적을 나타내는 area 메서드를 정의한다.
삼각형을 나타내는 Triangle 클래스, 타원을 나타내는 Ellipse 클래스 등 새로운 도형을 추가할 수도 있다.
전략 패턴
(strategy pattern) 이라고 한다.인터페이스는 switch 조건문의 중복을 제거할 수 있을 뿐만 아니라, 다중 중첩된 복잡한 분기를 제거하는 데 활용할 수 있다.
이러한 상황에서 유용하게 활용할 수 있는 패턴으로 정책 패턴
(policy pattern)이 있다.
정책 패턴
은 조건을 부품처럼 만들고, 부품으로 만든 조건을 조합해서 사용하는 패턴이다.예를 들어 다음의 상황을 가정해보자.
온라인 쇼핑몰에서 우수 고객인지 판정하는 로직이 있다.
고객의 구매 이력을 확인하고 다음 조건을 모두 만족하는 경우, 골드 회원으로 판정한다.
골드 회원의 구매 금액 규칙: 지금까지 구매한 금액이 100만원 이상
구매 빈도 규칙: 한 달에 구매하는 횟수가 10회 이상
반품률 규칙: 반품률이 0.1% 이하
아래의 코드처럼 하나하나의 규칙(판정 조건)을 나타내는 인터페이스를 만든다.
// 우수 고객 규칙을 나타내는 인터페이스
interface ExcellentCustomerRule {
/**
* @param history 구매 이력
* @return 조건을 만족하는 경우 true
*/
boolean ok(final PurchaseHistory history);
}
ExcellentCustormerRule
을 구현해서 만든다.// 골드 회원의 구매 금액 규칙
class GoldCustomerPurchaseAmountRule implements ExcellentCustomerRule {
public boolean ok(final PurchaseHistory history) {
return 1000000 <= history.totalAmount;
}
}
// 구매 빈도 규칙
class PurchaseFrequencyRule implements ExcellentCustomerRule {
public boolean ok(final PurchaseHistory history) {
return 10 <= history.purchaseFrequencyPerMonth;
}
}
// 반품률 규칙
class ReturnRateRule implements ExcellentCustomerRule {
public boolean ok(final PurchaseHistory history) {
return history.returnRate <= 0.001;
}
}
// 우수 고객 정책을 나타내는 클래스
class ExcellentCustomerPolicy {
private final Set<ExcellentCustomerRule> rules;
ExcellentCustomerPolicy() {
rules = new HashSet();
}
/**
* 규칙 추가
*
* @param rule 규칙
*/
void add(final ExcellentCustomerPolicy rule) {
rules.add(rule);
}
/**
* @param history 구매 이력
* @return 규칙을 모두 만족하는 경우 true
*/
boolean complyWithAll(final PurchaseHistory history) {
for(ExcellentCustomerRule each : rules) {
if(!each.ok(history)) return false;
}
return true;
}
}
Rule(ExcellentCustomerRule
)과 Policy(ExcellentCustomerPolicy)
을 사용해서 골드 회원 판정 로직을 개선했다.
그러면 골드 회원에 대한 정책을 아래와 같이 작성할 수 있다.
class GoldCustomerPolicy {
private final ExcellentCustomerPolicy policy;
GoldCustomerPolicy() {
policy = new ExcellentCustomerPolicy();
policy.add(new GoldCustomerPurchaseAmountRule());
policy.add(new PurchaseFrequencyRule());
policy.add(new ReturnRateRule());
}
/**
* @param history 구매이력
* @return 규칙을 모두 만족하는 경우 true
*/
boolean complyWithAll(final PurchaseHistory history) {
return policy.complyWithAll(history);
}
}
메모리: 72.3 MB, 시간: 11.40 ms - Answer Code1
메모리: 72.8 MB, 시간: 2.81 ms - Answer Code2
import java.util.*;
class Solution {
static long answer = 0;
static String op[] = {"+", "-", "*"};
static boolean[] visited = new boolean[3];
static ArrayList<Long> numList = new ArrayList<>(); // 피연산자, 숫자
static ArrayList<String> opList = new ArrayList<>(); // 연산자
static String[] perm = new String[3];
public long solution(String expresstion) {
String num = "";
//num op 구분
for(int i = 0; i < expresstion.length(); i++) {
char c = expresstion.charAt(i);
if(c == '*' || c == '+' || c == '-') { // 연산자인 경우
opList.add(c+"");
numList.add(Long.parseLong(num));
num = ""; // num을 초기화합니다. 새로운 피연산자를 시작하기 위해서 num을 비워줍니다.
} else { // 피연산자인 경우
num += c;
}
}
// 마지막 숫자
numList.add(Long.parseLong(num));
// 순열 만들기
makePermutation(0);
return answer;
}
static void makePermutation(int depth) {
if(depth == op.length) {
// 3개를 선택 -> 연산
sol();
return;
}
for(int i = 0; i < op.length; i++) {
if(visited[i]) continue;
visited[i] = true;
perm[depth] = op[i];
makePermutation(depth+1);
visited[i] = false;
}
}
static void sol() {
// list 복사
ArrayList<String> oper = new ArrayList<String>();
oper.addAll(opList);
ArrayList<Long> num = new ArrayList<Long>();
num.addAll(numList);
// 연산자 우선순위에 따라 계산
for(int i = 0; i < perm.length; i++) {
String op = perm[i];
for(int j = 0; j < oper.size(); j++) {
if(oper.get(j).equals(op)) {
long n1 = num.get(j);
long n2 = num.get(j+1);
long res = cal(n1, n2, op);
// list 갱신
num.remove(j+1);
num.remove(j);
oper.remove(j);
num.add(j, res);
j--;
}
}
}
answer = Math.max(answer, Math.abs(num.get(0)));
}
static long cal(long n1, long n2, String op) {
long result = 0;
switch(op) {
case "*":
result = n1 * n2;
break;
case "+":
result = n1 + n2;
break;
case "-":
result = n1 - n2;
break;
}
return result;
}
}
[풀이]
class Solution {
static long answer = 0;
static String op[] = {"+","-","*"}; // 가능한 연산자
static boolean[] visited = new boolean[3]; // 연산자들의 사용 여부
static ArrayList<Long> numList = new ArrayList<>(); // 숫자(피연산자)
static ArrayList<String> opList = new ArrayList<>(); // 연산자
static String[] perm = new String[3]; // 순열을 만들기 위한 배열
// 밑부분 생략
}
//num op 구분
for(int i = 0; i < expression.length(); i++) {
char c = expression.charAt(i);
if(c == '*' || c == '+' || c == '-') { // 연산자인 경우
opList.add(c+"");
numList.add(Long.parseLong(num));
num = ""; // num을 초기화합니다. 새로운 피연산자를 시작하기 위해서 num을 비워줍니다.
} else { // 피연산자인 경우
num += c;
}
}
연산자인 경우와 피연산자인 경우를 조건문으로 구분했다.
opList
에 추가한다.
numList.add(Long.parseLong(num));
은 지금까지 쌓인 숫자를 Long
타입으로 변환하여 numList
에 추가한다는 의미이다.num = “”;
을 한 이유는 새로운 피연산자를 시작하기 위해서 num
을 비워주는 것을 의미한다.num
에 더해준다.
numList.add(Long.parseLong(num));
에서 해당 숫자를 numList
에 넣어주게 된다.Long
타입으로 변환하여 numList
에 추가한다.이 코드는 주어진 수식을 순회하면서 연산자와 피연산자를 구분하여 opList
와 numList
에 저장하는 역할을 한다.
예를 들어, 수식이 “100-200300-500+20”인 경우에는 opList
에는 [”-“, “”, “-“, “+”]이 저장되고, numList
에는 [100, 200, 300, 500, 20]이 저장된다.
static void makePermutation(int depth) {
if(depth == op.length) {
// 3개를 선택 -> 연산
sol();
return;
}
for(int i = 0; i < op.length; i++) {
if(visited[i]) continue;
visited[i] = true;
perm[depth] = op[i];
makePermutation(depth+1);
visited[i] = false;
}
}
위 코드는 연산자의 우선순위의 모든 가능한 순열을 생성하는 메서드다. makePermutation
메서드는 재귀적으로 호출된다.
sol()
메서드를 호출하여 순열에 대한 연산을 수행한다.visited[i] = true;
의 경우 현재 연산자 op[i]
를 사용한다는 의미이다.makePermutation(depth+1);
을 통해 더 깊은 위치로 재귀 호출을 해서 다음 연산자를 선택한다.visited[i] = false;
는 현재 연산자 op[i]
를 다시 사용하지 않는 상태로 되돌린다.static void sol() {
// list 복사
ArrayList<String> oper = new ArrayList<String>();
oper.addAll(opList);
ArrayList<Long> num = new ArrayList<Long>();
num.addAll(numList);
// 연산자 우선순위에 따라 계산
for(int i = 0; i < perm.length; i++) {
String op = perm[i];
for(int j = 0; j < oper.size(); j++) {
if(oper.get(j).equals(op)) {
long n1 = num.get(j);
long n2 = num.get(j+1);
long res = cal(n1, n2, op);
// list 갱신
num.remove(j+1);
num.remove(j);
oper.remove(j);
num.add(j, res);
j--;
}
}
}
answer = Math.max(answer, Math.abs(num.get(0)));
}
static long cal(long n1, long n2, String op) {
long result = 0;
switch(op) {
case "*":
result = n1 * n2;
break;
case "+":
result = n1 + n2;
break;
case "-":
result = n1 - n2;
break;
}
return result;
}
oper
라는 새로운 문자열 리스트를 생성하고, 연산자 리스트의 복사본을 만든다.
num
이라는 새로운 Long 타입의 리스트를 생성하고 숫자 리스트의 복사본을 만든다.
연산자 우선순위의 모든 경우를 for문을 통해 반복한다.
op
에 저장한다.op
와 연산자 리스트의 원소가 일치하는 경우, 현재 연산자 앞 뒤에 있는 두 숫자를 n1
과 n2
에 저장한다.cal
메서드를 호출하여 n1
과 n2
를 op
로 계산한 결과를 res
에 저장한다.num.add(j, res);
**을 통해 계산 결과를 원래 순서에 넣는다.
res
값을 j
인덱스 위치에 추가하는 역할을 한다.num
리스트가 [1,2,3,4,5]이고, j
가 2이고, res
가 10이라면, 실행 후 num
리스트는 [1,2,10,3,4,5]가 된다.j
를 감소시켜서 다음 순회에 올바른 인덱스가 가리키도록 한다.static long cal(long n1, long n2, String op) { ... }
: 주어진 두 숫자와 연산자로 계산을 수행하는 메서드다.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
class Solution {
static List<List<String>> list = new ArrayList<>();
public long solution(String expression) {
long answer = 0;
boolean[] visited = new boolean[3];
String[] operationTypes = {"-","+","*"};
// 연산자 우선순위 모든 경우의 수 계산
permutation(new ArrayList<>(), operationTypes, visited);
// 연산자를 " "로 치환 후 split 값들을 Long 값으로 변환 후 toList
List<Long> numbers = Arrays.stream(expression.replaceAll("-|\\*|\\+"," ").split(" "))
.map(Long::parseLong).collect(Collectors.toList());
// 숫자를 공백으로 치환 후 toList
List<String> collect = Arrays.stream(expression.replaceAll("[0-9]", "").split(""))
.collect(Collectors.toList());
// 우선순위 모든 경우의 수에 따른 max 연산 값
for (List<String> strings : list) {
answer = Math.max(answer, solve(strings, numbers, collect));
}
return answer;
}
static long solve(List<String> strings, List<Long> numbers, List<String> operations) {
// 리스트에 remove 메서드를 사용하기 위해서 복사
List<Long> numbersClone = new ArrayList<>(numbers);
List<String> operationsClone = new ArrayList<>(operations);
for (int i = 0; i < strings.size(); i++) {
String operation = strings.get(i); // 현재 순위의 연산자
for(int j = 0; j < operationsClone.size(); j++) {
// 현재 순위의 연산자와 같은 경우
if(operation.equals(operationsClone.get(j))) {
// 연산자 앞의 수와 뒤의 수를 구한뒤 연산
long front = numbersClone.get(j);
long back = numbersClone.get(j + 1);
long temp = calc(front, back, operation);
// 연산에 사용된 두수를 제거
numbersClone.remove(j+1);
numbersClone.remove(j);
// 연산에 사용된 연산자 제거
operationsClone.remove(j);
// 연산된 값을 추가
numbersClone.add(j, temp);
// 연산이 진행되는 경우 2개 -> 1개 가되므로 리스트의 사이즈에 변경이 일어남
// 이를 맞추기 위해서 j를 --하여 다움 for문에서 범위를 벗어나지 않게 함
j--;
}
}
}
// 절대값으로 변환해서 리턴
return Math.abs(numbersClone.get(0));
}
static void permutation(ArrayList<String> arrayList, String[] orders, boolean[] visited) {
if(arrayList.size() == 3) {
list.add(arrayList);
return;
}
for (int i = 0; i < orders.length; i++) {
if (!visited[i]) {
ArrayList<String> temp = new ArrayList<>(arrayList);
temp.add(orders[i]);
visited[i] = true;
permutation(temp, orders, visited);
visited[i] = false;
}
}
}
static long calc(long one, long two, String calc) {
switch (calc) {
case "-" :
return one - two;
case "+" :
return one + two;
default :
return one * two;
}
}
}
해당 문제를 어떻게 접근해야 할지 감이 오질 않아서 다른 사람의 풀이를 참고했고, 해당 코드를 이해하느라 시간이 오래걸렸다.
순열과 dfs을 이용하여 문제를 풀었는데, 해당 코드를 이해하는데도 어려워서 해당 문제는 여러 번 복습해야겠다는 생각이 들었다.
두번째 정답은 첫번째에 비해 성능적으로 5배나 좋게 되어있어서 다음에 기회되면 두번째 풀이방식으로도 접근해야겠다.
이 글은 내 코드가 그렇게 이상한가요? 책을 읽고 정리한 내용을 바탕으로 작성하였습니다.
재할당
은 변수에 값을 다시 할당하는 것을 말하며, 파괴적 할당
이라고도 말한다.
재할당
은 변수의 의미를 바꿔 추측하기 어렵게 만들고,
언제 어떻게 변경되는지 추척하기 힘들게 한다.
따라서 재할당
을 막기 위해서는 변수
에 final 수식자를 붙여준다.
마찬가지로, 매개변수
에도 final 수식자를 붙인다.
함수의 부수 효과
는 함수가 매개변수를 전달받고, 값을 리턴하는 것 이외에 외부 상태(인스턴스 변수)를 변경하는 것을 가리킨다.
조금 더 구체적으로 설명하면, 함수(메서드)에는 주요 작용과 부수 효과가 있다.
주요 작용: 함수(메서드)가 매개변수를 전달받고, 값을 리턴하는 것
부수 효과: 주요 작용 이외의 상태 변경을 일으키는 것
여기서 상태 변경
이란 함수 밖에 있는 상태를 변경하는 것을 의미한다. 예를 들어 다음과 같은 것이다.
인스턴스 변수 변경
전역 변수 변경
매개변수 변경
파일 읽고 쓰기 같은 I/O 조작
작업 실행 순서에 의존하는 코드는 결과를 예측하기 힘들며, 유지 보수하기 힘들다.
부수 효과가 있는 함수는 영향 범위를 예측하기 힘들다.
따라서 예상치 못한 동작을 막으려면, 함수가 영향을 주거나 받을 수 있는 범위를 한정하는 것이다.
함수는 다음 동작을 만족하도록 설계하는 것이 좋다.
데이터(상태)는 매개변수로 받는다.
상태를 변경하지 않는다.
값은 함수의 리턴 값으로 돌려준다.
따라서 매개변수로 상태를 받고, 상태를 변경하지 않고, 값을 리턴하기만 함수
가 이상적이다.
지금까지 설명한 방식에 따라, 예상하지 못한 동작을 막기 위해 불변을 기반으로 코드를 설계한다.
부수 효과의 자체를 없애는 방법은 간단하다. 인스턴스 변수
에 final 수식자를 붙여서 불변으로 만들면 된다.
원시(기본) 타입(primitive type): byte, char, short, int, long, float, double, boolean
참조 타입(reference type): 배열 타입, 열거 타입, 클래스, 인터페이스
참고로, int형과 같은
원시 타입
(primitive)은 참조값이 존재하지 않기 때문에 외부에서도 그대로불변
으로 존재하게 된다.하지만,
참조 타입
인 객체나 Array, List와 같은 컬렉션일 경우에는불변
을 보장하려면 setter를 포함하지 않아야 하며, getter 사용시 방어적 복사를 통해 값을 전달해야 한다. 또한, 참조 변수 객체 내부 또한 불변이어야 불변이 성립한다.
불변 변수로 만들면 변경할 수 없기 때문에, 변경된 값을 사용하고 싶다면 새로운 값을 가진 새로운 인스턴스 변수를 만들어서 사용해야 한다.
아래 코드의 reinforce 메서드와 disable 메서드처럼 AttackPower
인스턴스를 새로 생성하고 리턴하는 구조로 변경한다.
class AttackPower() {
static final int MIN = 0;
final int value; // final로 불변으로 만들기
AttackPower(final int value) {
if(value < MIN) {
throw new IllegalArgumentException();
}
this.value = value;
}
/**
* 공격력 강화하기
* @param increment 공격력 증가량
* @return 증가된 공격력
*/
AttackPower reinforce(final AttackPower increment) {
return new AttackPower(this.value + increment.value); // 인스턴스를 새로 생성하고 리턴하는 구조로 변경
}
/**
* 무력화하기
* @return 무력화한 공격력
*/
AttackPower disable() {
return new AttackPower(MIN); // 인스턴스를 새로 생성하고 리턴하는 구조로 변경
}
}
지금까지 설명한 것처럼 변수를 불변으로 만들면 다음과 같은 장점이 있다.
변수의 의미가 변하지 않으므로, 혼란을 줄일 수가 있음.
동작을 안정적이게 되므로, 결과를 예측하지 쉬움
코드의 영향 범위가 한정적이므로, 유지 보수가 편리해짐
따라서 기본적으로는 불변
으로 설계하는 것이 좋다. (이 책에서도 불변을 표준 스타일로 사용한다)
기본적으로 불변으로 설계하는 것이 좋지만, 가변이 필요한 경우도 있다.
바로 성능(performance)이 중요한 경우이다.
대량의 데이터를 처리해야 하는 경우,
이미지를 처리하는 경우
리소스에 제약이 큰 임베디드 소프트웨어를 다루는 경우
위와 같은 예시에서는 가변을 사용하는 것이 좋을 수 있다.
불변이라면 값을 변경할 때 인스턴스를 새로 생성해야 한다.
만약 크기가 큰 인스턴스를 새로 생성하면서 시간이 오래 걸려 성능에 문제가 생긴다면 불변 보다는 가변을 사용하는 것이 좋다.
어떤 게임에서 히트포인터에 대한 기본적인 조건은 다음과 같다.
히트포인트는 0이상
히트포인트가 0이 되면, 사망 상태로 변경
히트포인트가 0이 될때 뮤테이터(mutater)를 통해 사망 상태로 변경하자.
뮤테이터(mutater): 상태를 변화시키는 메서드
class Hitpoint {
...
/**
* 대미지 받는 처리
* @param damageAmount 대미지 크기
*/
void damage(final int damageAmount) {
hitPoint.damage(damageAmount) {
if(hitPoint.isZero) {
states.add(StateType.dead); // 사망 상태로 변경
}
}
}
}
해당 문제를 풀면서 우선순위 큐에 대한 개념과 사용법을 다시 한번 짚고 넘어가고자 정리하게 되었다.
우선순위 큐
는 우선순위가 가장 높은 데이터를 가장 먼저 삭제하는 자료구조이다.
우선순위 큐는 데이터를 우선순위에 따라 처리하고 싶을 때 사용합니다.
우선순위 큐를 구현하는 방법은 다양하다.
(1) 단순히 리스트(List)
를 이용하여 구현할 수 있다.
(2) 힙(Heap)
을 이용하여 구현할 수 있다.
데이터의 개수가 N개일 때, 구현 방식에 따라서 시간 복잡도를 비교한 내용은 다음과 같다.
리스트 - 삽입 시간: O(1) | 삭제 시간: O(N) |
힙 - 삽입 시간: O(logN) | 삭제 시간: O(logN) |
단순이 N개의 데이터를 힙에 넣었다가 모두 꺼내는 작업은 정렬과 동일하다. (힙 정렬)
정리하면, 우선순위 큐는 배열, 연결리스트, 힙으로 구현이 가능하나 이 중 힙(Heap)으로 구현하는 것이 가장 효율적이다.
데이터를 삽입할 때 우선순위를 기준으로 최대 힙 또는 최소 힙을 구성하고
데이터를 꺼낼 때 루트 노드를 얻어낸 뒤
루트 노드를 삭제할 때는 빈 루트 노드 위치에 맨 마지막 노드를 삽입한 후
아래로 내려가면서 적절한 자리를 찾아 옮기는 방식으로 진행한다.
최대 값이 우선순위인 큐 = 최대힙, 최소 값이 우선순우이인 큐 = 최소 힙
힙
은 완전 이진 트리 자료구조의 일종이다.
힙에서는 항상 루트 노드(root node)를 제거한다.
최소 힙(min heap)
루트 노드가 가장 작은 값을 가진다.
따라서 값이 작은 데이터가 우선적으로 제거된다.
최대 힙(max heap)
루트 노드가 가장 큰 값을 가진다.
따라서 값이 큰 데이터가 우선적으로 제거된다.
완전 이진 트리란 루트 노트부터 시작하여 왼쪽 자식 노드, 오른쪽 자식 노드 순서대로 데이터가 차례대로 삽입되는 트리를 의미한다.
원소를 제거할 때는 가장 마지막 노드가 루트 노드의 위치에 오도록 한다.
높은 우선순위의 요소를 먼저 꺼내서 처리하는 구조이다. (우선순위 큐에 들어가는 원소는 비교가 가능한 기준이 있어야 한다)
내부 요소는 힙으로 구성되어 이진 트리
이루어져 있다.
내부 구조가 힙으로 구성되어 있기에 시간 복잡도는 O(NlogN)
이다.
우선순위를 중요시해야 하는 상황에서 주로 쓰인다.
Priority Queue
를 사용하려면 java.util.PriorityQueue
를 import해야 한다.import java.util.PriorityQueue;
import java.util.Collections;
public class PriorityQueue() {
//낮은 숫자가 우선 순위인 int 형 우선순위 큐 선언
PriorityQueue<Integer> priorityQueueLowest = new PriorityQueue<>();
//높은 숫자가 우선 순위인 int 형 우선순위 큐 선언
PriorityQueue<Integer> priorityQueueHighest = new PriorityQueue<>(Collections.reverseOrder());
}
import java.util.PriorityQueue;
public class PriorityQueueDemo {
public static void main(String[] args) {
PriorityQueue<Integer> pq = new PriorityQueue<>();
// 1, 15, 10, 21, 25, 18, 8 값 추가
pq.add(1);
pq.add(15);
pq.offer(10);
pq.add(21);
pq.add(25);
pq.offer(18);
pq.add(8);
System.out.print(pq); // 결과 출력: [1, 15, 8, 21, 25, 18, 10]
}
}
우선순위 큐에 값을 추가하는 방법은 add()
, offer()
메서드가 있다.
add(value)
메서드는 삽입이 성공하면 true를 반환하고, 큐에 여유 공간이 없어 삽입에 실패하면 IllegalStateException 을 발생시킨다.add()
메서드는 Collection 클래스에서 가져오는 메서드라면, offer()
메서드는 Queue 클래스에서 가져오는 메서드다.
결과를 보면 값을 입력한 순서가 아닌 우선순위 큐만의 정렬방식으로 출력된다.
결과: 1, 15, 8, 21, 25, 18, 10
추가할 때 우선순위 큐의 동작은 아래와 같은 과정을 통해 정렬이 된다.
import java.util.PriorityQueue;
public class PriorityQueueDemo {
public static void main(String[] args) {
PriorityQueue<Integer> pq = new PriorityQueue<>();
// 1, 15, 10, 21, 25, 18, 8 값 추가
pq.add(1);
pq.add(15);
pq.offer(10);
pq.add(21);
pq.add(25);
pq.offer(18);
pq.add(8);
System.out.println(pq); // 결과 출력: [1, 15, 8, 21, 25, 18, 10]
pq.poll();
System.out.println(pq); // 결과 출력: [8, 15, 10, 21, 25, 18]
pq.remove();
pq.remove(1);
System.out.println(pq); // 결과 출력: [10, 15, 18, 21, 25]
pq.clear();
System.out.println(pq); // 결과 출력: []
}
}
우선순위 큐에서 값을 삭제하는 방법은 여러가지가 있다.
poll()
, remove()
메서드를 사용하면 우선순위가 가장 높은 값을 제거한다.
poll()
메서드는 첫번째 값을 반환하고 제거한다. 만약 우선순위 큐가 비어있다면 null을 반환한다.
remove()
메서드는 첫번째 값을 제거한다.
clear()
메서드를 사용하면 우선순위의 큐의 모든 값을 삭제한다. (=초기화)
결과를 보면 첫 번째 우선순위를 삭제하면 하나씩 밀리는 것이 아니라 우선순위를 재정렬하는 것을 확인할 수 있다.
삭제할 때, 우선순위 큐의 동작은 아래와 같은 과정을 통해 정렬이 된다.
import java.util.Iterator;
import java.util.PriorityQueue;
public class PriorityQueueDemo {
public static void main(String[] args) {
PriorityQueue<Integer> pq = new PriorityQueue<>();
// 1, 15, 8, 21, 25, 18, 10 값 추가
pq.add(1);
pq.add(15);
pq.offer(10);
pq.add(21);
pq.add(25);
pq.offer(18);
pq.add(8);
System.out.println(pq.peek()); // 결과 출력: 1
Iterator iterator = pq.iterator();
while(iterator.hasNext())
System.out.print(iterator.next() + " "); // 결과 출력: 1 15 8 21 25 18 10
}
}
Priority Queue에서 peek()
메서드를 사용하면 우선순위가 가장 높은 값이 출력된다.
전체 Queue의 값을 가져오려면 Iterator() 메서드를 사용하여 가져오면 된다.
import java.util.*;
class Solution {
static class Task {
private String name;
private int start;
private int playtime;
public Task(String name, int start, int playtime) {
this.name = name;
this.start = start;
this.playtime = playtime;
}
public Task(String name, int playtime) {
this.name = name;
this.playtime = playtime;
}
}
public List<String> solution(String[][] plans) {
// 정답을 저장할 리스트
List<String> answer = new ArrayList<>();
// 해야할 과제들을 시작시간 순으로 저장
PriorityQueue<Task> pq = new PriorityQueue<>(
(o1, o2) -> (o1.start - o2.start)
);
for(int i = 0; i < plans.length; i++) {
String name = plans[i][0];
String[] str = plans[i][1].split(":");
int h = Integer.parseInt(str[0]);
int m = Integer.parseInt(str[1]);
int start = (h * 60) + m; // '분' 기준으로 합침
int time = Integer.parseInt(plans[i][2]);
pq.add(new Task(name, start, time));
}
// 잠시 멈춘 과제를 저장
Stack<Task> remainingTasks = new Stack<>();
while(!pq.isEmpty()) {
Task currentTask = pq.poll();
String curName = currentTask.name;
int curStart = currentTask.start;
int curPlaytime = currentTask.playtime;
// 현재 시각
int currentTime = curStart;
// 1) 새로운 과제가 남아있는 경우(진행중이던 과제 제외)
if(!pq.isEmpty()) {
Task nextTask = pq.peek();
// 1-1) 지금 과제를 끝내고도 다음 과제 시작까지 시간이 남는 경우
if(currentTime + curPlaytime < nextTask.start) {
answer.add(curName);
currentTime += curPlaytime;
// 잠시 멈춘 과제가 있는 경우, 남는 시간동안 멈췄던 과제 해결
while(!remainingTasks.isEmpty()) {
Task rem = remainingTasks.pop();
// 다음 새로운 과제 시작전까지 다 끝낼수 있는 경우
if(currentTime + rem.playtime <= nextTask.start) {
currentTime += rem.playtime;
answer.add(rem.name);
continue;
}
// 다음 새로운 과제 시작전까지 못 끝내는 경우
else {
int t = rem.playtime - (nextTask.start - currentTime);
// 추가로 한 시간만 빼서 멈춘 과제 목록에 다시 추가
remainingTasks.push(new Task(rem.name, t));
break;
}
}
}
// 1-2) 지금 과제 끝내면 새로운 과제 시작할 시간인 경우
else if(curStart + curPlaytime == nextTask.start) {
answer.add(curName);
continue;
}
// 1-3) 새로운 과제 시작전까지 지금 과제를 못 끝내는 경우
else {
int t = (nextTask.start - currentTime);
remainingTasks.push(new Task(curName, curPlaytime - t));
}
}
// 2) 더 이상 남아있는 새로운 과제가 없는 경우
else {
// 2-1) 남아있는 과제(잠시 멈춘 과제)도 없는 경우
if(remainingTasks.isEmpty()) {
currentTime += curPlaytime;
answer.add(curName);
}
// 2-2) 남아있는 과제는 있는 경우
else {
answer.add(curName); // 새로운 과제부터 먼저 해결
// 남아있는 과제들을 정해진 순서대로 끝내면 됨
while(!remainingTasks.isEmpty()) {
Task rem = remainingTasks.pop();
answer.add(rem.name);
}
}
}
}
return answer;
}
}
과제를 진행할 때 과제의 시작 시각
(start)을 기준으로 하므로 PriorityQueue
를 사용해서 과제들의 정보를 시작 시간
순으로 저장합니다.
그리고 멈춰둔 과제가 여러 개 있는 경우 가장 최근에 멈춘 과제부터 시작한다는 점에서 멈춘 과제들을 저장하기 위해 Stack
을 사용했습니다.
새로운 과제와 잠시 멈춘 과제를 고려해서 여러 경우의 수를 생각하고, 각각의 상황에 따라 적합한 코드를 작성하여 문제를 해결하는 것이였습니다.
구현에 대해 설명은 자세하나 그 속은 복잡한 문제였다.
경우의 수가 많고, 그에 따라 문제를 푸는데 헷갈려서 아이패드를 이용했다.
해당 문제를 나중에 다시 복습하면서, 우선순위 큐와 스택을 활용하는 것에 익숙해지자.
이 글은 내 코드가 그렇게 이상한가요? 책을 읽고 정리한 내용을 바탕으로 작성하였습니다.
잘 만들어진 클래스는 다음 두 가지로 구성된다.
인스턴스 변수
인스턴스 변수에 잘못된 값이 할당되지 않게 막고, 정상적으로 조작하는 메서드
자신의 몸은 자신이 지켜야 하듯이, 클래스 스스로 자기 방어 임무를 수행할 수 있어야 소프트웨어의 품질을 높이는 데 도움이 된다.
데이터 클래스를 성숙한 클래스로 가는 과정을 알아가보자.
금액을 나타내는 클래스인 Money를 예시로 들면,
금액을 나타내는 클래스
import java.util.Currency;
class Money {
int amount; // 금액
Currency currency; // 통화 단위
}
위의 Money 클래스는 인스턴스 변수만 갖고 있는 전형적인 데이터 클래스이다.
일단 인스턴스 변수를 모두 초기화하는 데 필요한 매개변수들을 받는 생성자를 만든다.
그리고 잘못된 값이 유입되지 못하게 유효성 검사
(validation)를 생성자 내부에 정의한다.
금액 amount: 0 이상의 정수
통화 currency: null 이외의 것
생성자에서 유효성 검사하기
import java.util.Currency;
class Money {
int amount; // 금액
Currency currency; // 통화 단위
Money(int amount, Currency currency) {
if (amount < 0) {
throw new IllegalArgumentException("금액은 0 이상의 값을 지정해 주세요.");
}
if (currency == null) {
throw new NullPointerException("통화 단위를 지정해 주세요. ");
}
this.amount = amount;
this.currency =currency;
}
}
이렇게 하면 올바른 값만 인스턴스 변수에 저장할 수 있을 것이다.
위의 클래스 처럼 처리 범위를 벗어나는 조건을 메서드 가장 앞 부분에서 확인하는 코드를 가드
라고 부른다.
생성자에 가드를 배치함으로써, 잘못된 값이 전달되면 생성자에서 예외가 발생할 것이다.
데이터
와 데이터를 조작하는 로직
이 분리되어 있는 구조를 응집도가 낮은 구조라고 한다.
이런 문제를 막기 위해서는 계산 로직도 Money 클래스 내부에 구현하면 된다.
변수의 값이 계속해서 바뀌면, 나중에 비즈니스 요구 사항이 바뀔때마다 코드를 수정하다가 의도하지 않는 값을 할당하는 예상치 못한 부수 효과가 쉽게 발생할 수도 있다.
이를 막으려면, 인스턴스 변수를 불변으로 만든다.
값을 한 번 할당하면 다시는 바꿀 수 없는 변수를 불변 변수
라고 한다.
불변 변수로 만들려면 final
수식자를 사용한다.
final을 붙여 불변 변수로 만들기
import java.util.Currency;
class Money {
final int amount; // 금액
final Currency currency; // 통화 단위
Money(int amount, Currency currency) {
if (amount < 0) {
throw new IllegalArgumentException("금액은 0 이상의 값을 지정해 주세요.");
}
if (currency == null) {
throw new NullPointerException("통화 단위를 지정해 주세요. ");
}
this.amount = amount;
this.currency =currency;
}
}
final
수식자를 붙이면, 한 번만 할당할 수 있고 이후에는 재할당할 수 없다.import java.util.Currency;
class Money {
final int amount; // 금액
final Currency currency; // 통화 단위
Money add(int other) {
int added = amount + other;
return new Money(added, currency);
}
}
값이 중간에 바뀌는 것을 방지하기 위해 기본적으로 매개변수는 변경하지 않는 것이 좋다.
(값이 중간에 바뀌면, 값의 변화를 추적하기 힘들기 때문에 버그를 발생하기도 한다)
매개변수에 final
을 붙이면 값을 변경할 수 없게 된다.
지역 변수도 마찬가지로 중간에 값이 변경될 수 있으므로 final
을 붙여 불변으로 만든다.
import java.util.Currency;
class Money {
// 생략
Money add(final int other) { // 매개변수에 final 붙임
final int added = amount + other; // 지역 변수에 final 붙임
return new Money(added, currency);
}
}
import java.util.Currency;
class Money {
// 생략
Money add(final Money other) { // Money 자료형을 매개변수로 받음
final int added = amount + other.amount;
return new Money(added, currency);
}
}
기본 자료형 위주로 사용하면, 나중에 실수로 의미가 다른 값을 전달하기 쉽다.
반면 Money처럼 독자적인 자료형을 사용하면, 의미가 다른 값을 전달할 경우 컴파일 오류가 발생할 수 있다.
지금까지 악마 퇴치를 위한 객체 지향 설계의 기본을 살펴보았다.
Money 클래스의 소스 코드와 클래스 다이어그램을 살펴보면 아래와 같다.
관련 로직을 응집해서 코드 수정 시 버그 발생이 어려워진 Money 클래스
import java.util.Currency;
class Money {
final int amount; // 금액
final Currency currency; // 통화 단위
Money(int amount, Currency currency) {
if (amount < 0) {
throw new IllegalArgumentException("금액은 0 이상의 값을 지정해 주세요.");
}
if (currency == null) {
throw new NullPointerException("통화 단위를 지정해 주세요. ");
}
this.amount = amount;
this.currency =currency;
}
Money add(final Money other) {
if(!currency.equals(other.currency)) {
throw new IllegalArgumentException("통화 단위가 다릅니다.");
}
final int added = amount + other.amount;
return new Money(added, currency);
}
}
퇴치된 악마 - 이유
중복 코드 최소화 - 필요한 로직이 Money 내부 클래스에 모여있어, 다른 코드에 중복 코드를 작성할 일이 줄어듦.
수정 누락 최소화 - 중복 코드가 발생하지 않으므로, 수정 시 누락이 발생할 일이 줄어듦.
가독성 개선 - 필요한 로직이 Money 내부 클래스에 모여있어, 디버깅 또는 기능 변경시 관련 로직이 모여있어서 가독성이 높아짐.
쓰레기 객체 발생 X - 생성자에서 인스턴스 변수의 값을 확정함.
잘못된 값 X - 잘못된 값이 나오지 않도록 유효성 검사를 앞 부분에 처리하고, 인스턴스 변수에 final 수식자를 붙여 불변으로 만듦.
생각하지 못한 부수 효과 - final 수식자를 붙여 불변 변수로 만들었으므로, 부수 효과로부터 안전함.
값 전달 실수 - 매개변수를 Money 자료형으로 바꿨으므로, 다른 자료형의 값을 실수로 넣으면 컴파일 오류가 발생하도록 함.
이처럼 클래스 설계
란 인스턴스 변수가 잘못된 상태에 빠지지 않게 하기 위한 구조를 만드는 것이라고 해도 과언이 아니다.
Money 클래스처럼 로직이 한곳에 모여 있는 구조는 응집도가 높은 구조라고 한다.
또한 데이터
와 그 데이터를 조작하는 로직
을 하나의 클래스로 묶고, 필요한 절차(메서드)만 외부에 공개하는 것을 캠슐화
라고 한다.
디자인 패턴
은, 응집도가 높은 구조를 만드는 등 프로그램의 구조를 개선하는 설계 방법이라고 한다.
몇 가지 디자인 패턴을 소개하면 표 3.2와 같다.
완전 생성자
는 잘못된 상태로부터 클래스를 보호하기 위한 디자인 패턴이다.
다음 2가지로 설계하면 값이 모두 정상인 완전한 객체로 만들어질 것이다.
인스턴스 변수를 모두 초기화해야지만 객체를 생성할 수 있게, 매개변수를 가진 생성자를 만든다.
생성자 내부에 가드를 사용해서 잘못된 값이 들어오지 않게 만든다.
값 객체
란 값을 클래스(자료형)을 나타내는 디자인 패턴이다.
예를 들어 금액을 단순한 int 자료형의 지역 변수 또는 매개변수로 사용하면, 금액 계산 로직이 이곳저곳에 분산될 것이다.
추가로 주문 수, 할인 포인트까지 int 자료형으로 사용한다면, 실수로 의미가 다른 값들이 섞일 수도 있다.
이러한 상황을 막으려면, 값을 클래스로 정의하면 된다.
금액을 더하는 경우, Money.add 메서드와 같이 매개변수로 Money 자료형만 받을 수 있도록 하면, 의도하지 않게 다른 값이 섞이는 상황을 원천적으로 차단할 수 있다.
값 객체 + 완전 생성자
는 객체 지향 설계에서 폭넓게 사용되는 기법이라고 할 수 있다.