Java
HashMap과 TreeMap은 언제 사용할까?
자바는 수많은 제네릭 컬렉션을 지원한다.
그 덕에 우리는 개발할 때 List, Stack, Map, Set 등을 간편하게 활용할 수 있다.
그런데 제네릭 컬렉션 중 많은 종류는 클래스가 아닌 인터페이스 형식으로 지원된다.
같은 자료 구조라고 하더라도 다른 방식으로 구현될 수 있기 때문이다.
가장 대표적인 자료 구조인 List 또한 ArrayList, LinkedList 등 여러 구현체가 있다.
이번 포스트에서 알아볼 Map 또한 HashMap과 TreeMap 등의 구현체들이 있다.
이러한 구현체들은 저마다 존재 이유가 있어서, 그 특징을 알고 상황에 맞게 사용할 수 있어야 한다.
그리고 그 특징을 알기 위한 가장 좋은 방법은 각 구현체가 어떻게 인터페이스를 구현하는지 이해하는 것이다.
이번 포스트에서는 Map 인터페이스를 구현하는 HashMap과 TreeMap이 Map의 기능들을 어떻게 구현하는지, 그리고 그 구현 방법에 의해 발생하는 특징은 무엇인지 살펴보자.
Map 인터페이스
Map의 구현체를 살펴보기 전, Map 자체에 대해 이해해 보도록 하자.
Map의 특징
Map은 다음과 같은 특징을 갖는다.
1. Key-Value 쌍
Map은 데이터를 key-value 쌍으로 저장하는 자료 구조이다. 각각의 값(value)은 고유한 키(key)를 통해 식별된다.
학생 정보를 저장하는 Map:
{
"홍길동": 95,
"김철수": 88,
"이영희": 92
}
위 예시에서 "홍길동", "김철수", "이영희"는 각각 학생 이름(key)을 나타내고, 95, 88, 92는 각 학생의 점수(value)를 나타낸다.
2. 유일한 Key
Map에서 각 키는 반드시 유일해야 한다. 동일한 키를 가진 데이터가 여러 개 존재할 수 없다. 만약 동일한 키로 새로운 값을 추가하면 기존 값은 새로운 값으로 덮어씌워진다.
기존 Map: { "홍길동": 95 }
새로운 값 추가: { "홍길동": 98 }
결과 Map: { "홍길동": 98 }
위 예시에서 "홍길동"의 키를 갖는 새로운 데이터를 추가함으로써, 기존에 있던 홍길동의 점수가 98로 변경되었다.
3. 순서가 특정되지 않음
Map은 저장되는 데이터의 순서를 보장하지 않는다. 데이터를 추가한 순서대로 값을 가져오거나 특정 위치의 값을 참조할 수 없다.
Map: { "홍길동": 95, "김철수": 88, "이영희": 92 }
위 Map에서 "홍길동", "김철수", "이영희" 순서로 값을 가져올 수 있다는 보장이 없다.
Map의 핵심 연산
Map의 구현체들은 위 특징들을 지키면서 다음의 연산을 지원해야 한다.
1. put(key, value)
put(key, value) 연산은 Map에 새로운 key-value 쌍을 추가하거나, 기존 키에 대한 값을 변경하는 데 사용된다.
새로운 key-value 쌍 추가: 만약 주어진 키가 Map에 존재하지 않으면, 새로운 key-value 쌍이 Map에 추가된다.
기존 값 변경: 만약 주어진 키가 이미 Map에 존재하면, 해당 키에 연결된 기존 값은 새로운 값으로 덮어씌워진다.
2. get(key)
get(key) 연산은 주어진 키에 해당하는 값을 Map에서 찾아 반환한다.
값 존재: 만약 주어진 키가 Map에 존재하면, 해당 키에 연결된 값이 반환된다.
값 미존재: 만약 주어진 키가 Map에 존재하지 않으면, null이 반환된다.
3. remove(key)
remove(key) 연산은 주어진 키에 해당하는 key-value 쌍을 Map에서 제거한다.
HashMap 클래스
HashMap은 **해시 테이블(Hash Table)**을 기반으로 동작하는 Map 인터페이스의 대표적인 구현체이다. 빠른 데이터 검색, 삽입, 삭제를 제공하며 다양한 활용 분야에서 사용된다.
구현 방식
해시 함수
HashMap은 해시 함수를 통해 데이트를 분류한다. 해시 함수는 데이터를 고정된 크기의 값으로 변환하는 함수이다. 이렇게 변환된 값을 해시 값(Hash Value) 또는 **해시 코드(Hash Code)**라고 한다.
해시 버킷
해시 함수로 얻은 해시 값을 인덱스로 사용하여, 데이터를 버킷(Bucket)이라는 배열에 저장한다.
충돌 해결
해시는 필연적으로 충돌이 발생한다. 즉, 서로 다른 두 데이터가 같은 해시 값을 뱉을 수 있다. 이를 해결하기 위해 각 버킷에 연결 리스트 (Linked List)를 두어 충돌된 데이터들을 관리한다.
시간 복잡도
데이터의 해시 값을 이용해 배열에 접근하므로 삽입, 검색, 삭제 연산 모두 O(1)의 시간 복잡도를 기대할 수 있다.
하지만 최악의 경우, 모든 데이터에 대해 해시 충돌이 발생하게 되면 모든 데이터가 연결 리스트로 관리되게 되어 O(n)의 시간 복잡도를 가진다.
사용하는 상황
HashMap은 모든 연산에 상수 시간을 기대할 수 있는 매우 빠른 자료 구조이다.
하지만 해시 충돌 시 빠른 연산의 장점이 사라지므로 데이터의 해시값 분포가 잘 되어있는지를 생각해야 한다.
TreeMap 클래스
TreeMap은 Map 인터페이스를 구현한 또 다른 자료 구조로, **균형 이진 탐색 트리(Red-Black Tree)**를 기반으로 동작한다.
균형 이진 탐색 트리
TreeMap은 데이터를 저장할 때 키를 기준으로 균형 이진 탐색 트리에 삽입한다. 이진 탐색 트리는 왼쪽 자식 노드의 키는 부모 노드의 키보다 작고, 오른쪽 자식 노드의 키는 부모 노드의 키보다 크거나 같은 특징을 가진다. 균형 이진 탐색 트리는 트리의 높이를 최소화하여 탐색, 삽입, 삭제 연산의 효율성을 높인다.
정렬된 Map
TreeMap은 키를 기준으로 정렬된 상태를 유지한다. 따라서 데이터를 추가한 순서와 상관없이 키의 순서대로 값을 가져올 수 있다. 이는 HashMap과 가장 큰 차이점 중 하나이다.
시간복잡도
평균적으로 O(log n)의 시간 복잡도를 가진다. 균형 이진 탐색 트리의 특성상 탐색, 삽입, 삭제 연산 시 트리의 높이만큼 비교 연산을 수행하기 때문이다.
이는 평균적으로 상수 시간의 시간 복잡도를 갖는 HashMap보다는 느리지만, TreeMap은 최악의 경우에도 O(log n)의 시간 복잡도를 보장한다.
사용하는 상황
TreeMap은 키를 기준으로 정렬된 상태를 유지하므로, 정렬 상태가 중요할 때 사용할 수 있다.
또, 이를 이용해 특정 범위 내의 키를 가진 데이터를 빠르게 찾을 수 있다.
결론
위 내용을 정리하면 다음과 같다.
따라서 다음의 기준에 따라 HashMap과 TreeMap 중 어떤 것을 사용할 지 결정할 수 있다.
HashMap을 사용하는 것이 좋은 경우
데이터의 순서가 중요하지 않은 경우
빠른 삽입, 삭제, 검색 속도가 중요한 경우
데이터의 규모가 크고 해시 충돌이 적을 것으로 예상되는 경우
TreeMap을 사용하는 것이 좋은 경우
데이터를 정렬된 상태로 유지해야 하는 경우
특정 범위 내의 키를 가진 데이터를 빠르게 찾아야 하는 경우 (범위 검색)
데이터의 순위 정보를 관리해야 하는 경우
02024. 07. 20.
Java
람다에서 로컬 변수 사용하기 - local variables referenced from a lambda expression must be final or effectively final
자바 코드를 작성하다보면 콜백을 사용해야 할 때가 있다.
콜백은 람다로 작성하는 일이 많은데, 이 때 다음과 같은 에러가 발생하는 경우가 있다.
java: local variables referenced from a lambda expression must be final or effectively final
이번 포스트에서는 이 에러가 나는 이유와 이를 해결할 수 있는 여러 방법들을 소개하고자 한다.
시나리오
에러를 재현하기 위해 다음과 같은 상황을 가정해보자.
긴 정수 배열에 대해 특정 작업을 반복해야 한다.
중간 중간 진행 상황을 알고자 한다.
이를 위한 메서드 runTasks()를 다음과 같이 작성했다고 하자.
private static void runTasks(int[] array, Consumer<Integer> onProgressUpdate) {
int tasksDone = 0;
for (int element : array) {
// 오래 걸리는 작업
task(element);
// 10개 작업이 끝날 때 마다 진행도 업데이트
if (++tasksDone % 10 == 0) {
onProgressUpdate.accept(tasksDone);
}
}
if (tasksDone % 10 != 0) {
onProgressUpdate.accept(tasksDone);
}
}
이 메서드를 호출할 때, 진행 상황을 다음과 같이 트래킹해보자.
public static void trackProgress() {
int currentProgress = 0;
runTasks(
new int[] {},
(progress) -> {
System.out.println("진행 상황: " + progress);
currentProgress = progress;
});
}
currentProgress = progress; 부분에서 문제의 에러가 발생함을 확인할 수 있다.
에러의 원인
에러 메세지를 보면 람다에서 참조하는 지역 변수는 final이거나 effectively final이어야 한다고 한다.
final 변수는 초기화 이후 값 변경이 불가능한 변수이며,
effectively final 변수는 final 키워드가 명시적으로 붙지는 않았지만 사실상 값이 변경되지 않는 변수를 의미한다.
위 예시에서는 currentProgress 변수가 람다 내부에서 값이 변경되기 때문에 문제가 발생하는 것이다.
왜 이러한 제약이 있는걸까?
람다에서 지역 변수의 변경이 제한되는 이유
람다에서 지역 변수의 값 변경이 제한되는 이유를 이해하기 위해 람다 표현식의 특성과 자바의 변수 스코프 개념을 되짚어보자.
람다는 자신을 둘러싼 외부 범위의 변수에 접근할 수 있다. (람다 캡처링, lambda capturing)
람다는 메서드 실행 종료 후에도 비동기적으로 실행될 수 있지만, 지역 변수는 메서드 실행이 종료되면 스택에서 사라진다.예를 들어, runTasks()에 전달된 람다를 클래스 멤버 변수로 저장해 놓았다고 생각해보자.
trackProgress() 메서드가 종료되는 시점에 currentProgress 변수는 스택에서 할당 해제된다.
반면, 전달된 onProgressUpdated 람다 객체는 여전히 살아있다.
이러한 문제를 해결하기 위해 자바는 람다 표현식이 캡처하는 지역 변수를 복사하여 별도의 공간에 저장하고, 람다 표현식은 복사된 값을 사용하도록 한다. 만약 원본 지역 변수의 값이 변경될 수 있다면 복사된 값과의 일관성이 깨져 오류가 발생할 수 있다.
따라서 람다 표현식 내부에서 사용되는 지역 변수는 final 또는 effectively final로 선언하여 값 변경을 방지해야 한다. 이를 통해 람다 표현식이 안전하게 변수에 접근하고 사용할 수 있도록 보장할 수 있다.
해결 방법
위에서 살펴보았듯, 람다에서 지역 변수를 직접 변경하는 것은 어렵다. 이를 해결할 수 있는 방법들에 대해 알아보자.
배열로 감싸기
currentProgress를 배열로 감싸는 것으로 이 문제를 우회할 수 있다. 자바에서 배열은 객체이므로, 람다 표현식 내부에서 배열의 요소 값을 변경하는 것은 허용된다.
private static void trackProgress() {
int[] currentProgress = {0}; // 배열로 감싸기
runTasks(
new int[] {},
(progress) -> {
System.out.println("진행 상황: " + progress);
currentProgress[0] = progress; // 배열 요소 변경
});
}
위 코드에서 currentProgress는 길이가 1인 정수 배열로 선언되었다. 람다 표현식 내부에서는 currentProgress[0]을 변경하여 진행 상황을 업데이트한다. 이렇게 하면 currentProgress 변수 자체는 변경되지 않고 배열의 요소 값만 변경되므로, effectively final 조건을 만족하게 된다.
이 방법은 가장 간단하게 문제를 해결할 수 있는 방법 중 하나이지만, 코드 가독성이 떨어지고 멀티 스레드 환경에서는 문제가 발생할 수 있다는 점을 유의해야 한다. 단일 스레드 환경에서 간단하게 문제를 해결하고 싶을 때 유용하게 사용할 수 있다.
장점
간단하고 빠르게 적용 가능: 코드를 크게 수정하지 않고도 문제를 해결할 수 있다.
기존 코드의 수정 최소화: 람다 표현식 외부의 코드를 거의 변경하지 않아도 된다.
단점
코드 가독성 저하: 배열을 사용하는 의도를 명확히 파악하기 어려워 코드 가독성이 떨어질 수 있다. 다른 개발자가 코드를 이해하기 어려울 수 있다.
멀티 스레드 환경에서 동기화 문제 발생 가능: 멀티 스레드 환경에서는 여러 스레드가 동시에 currentProgress[0] 값을 변경하려고 할 때 문제가 발생할 수 있다.
멤버 필드로 만들기
두 번째 해결 방법은 currentProgress 변수를 클래스의 멤버 필드로 선언하는 것이다. 멤버 필드는 람다 표현식의 외부에 선언되므로, 람다 표현식이 캡처하는 변수에 해당하지 않는다. 따라서 effectively final 조건의 적용 대상이 아니며, 람다 표현식 내부에서 자유롭게 값을 변경할 수 있다.
private static int currentProgress = 0; // 멤버 필드로 선언
private static void trackProgress() {
runTasks(
new int[] {},
(progress) -> {
System.out.println("진행 상황: " + progress);
currentProgress = progress; // 멤버 필드 변경
});
}
멤버 필드를 사용하는 방법은 람다 표현식 내부에서 변수에 직접 접근할 수 있어 코드가 간결해지고 가독성이 높아진다는 장점이 있다. 하지만 멀티 스레드 환경에서는 동기화 문제가 발생할 수 있으므로 주의해야 한다. 또한, 객체 지향 설계 원칙을 고려하여 신중하게 사용해야 한다.
장점
람다 표현식 내부에서 직접 접근 가능: 람다 표현식 내부에서 currentProgress 변수에 직접 접근하여 값을 변경할 수 있으므로, 코드가 더 간결해진다.
코드 가독성 향상: 배열을 사용하는 것보다 의도를 파악하기 쉽고, 코드 가독성이 높아진다.
단점
멀티 스레드 환경에서 동기화 문제 발생 가능: 멀티 스레드 환경에서는 여러 스레드가 동시에 currentProgress 값을 변경하려고 할 때 문제가 발생할 수 있다.
객체 지향 설계 원칙에 어긋날 수 있음: 멤버 필드를 사용하면 클래스의 상태가 외부에 노출될 수 있으며, 이는 객체 지향 설계 원칙에 어긋날 수 있다.
AtomicInteger 사용하기
세 번째 해결 방법은 AtomicInteger 클래스를 사용하여 currentProgress 변수를 선언하는 것이다. AtomicInteger는 멀티 스레드 환경에서 안전하게 정수 값을 변경할 수 있도록 설계된 클래스이다.
private static void trackProgress() {
AtomicInteger currentProgress = new AtomicInteger(0); // AtomicInteger 사용
runTasks(
new int[] {},
(progress) -> {
System.out.println("진행 상황: " + progress);
currentProgress.set(progress); // AtomicInteger 값 변경
});
}
위 코드에서 currentProgress는 AtomicInteger 객체로 선언되었다. 람다 표현식 내부에서는 currentProgress.set(progress) 메서드를 호출하여 진행 상황을 업데이트한다. AtomicInteger는 내부적으로 동기화 처리를 수행하므로, 멀티 스레드 환경에서도 안전하게 값을 변경할 수 있다.
이 방법은 멀티 스레드 환경에서 안전하게 값을 변경해야 하는 경우 가장 적합한 방법이다. Atomic 클래스의 다양한 기능을 활용하여 효율적인 코드를 작성할 수 있다. 단일 스레드 환경에서는 굳이 AtomicInteger를 사용할 필요는 없지만, 멀티 스레드 환경으로 확장될 가능성이 있다면 미리 AtomicInteger를 사용하는 것을 고려해 볼 수 있다.
또, 지역 변수로 선언할 수 있으므로 캡슐화 원칙도 지킬 수 있다.
장점
멀티 스레드 환경에서 안전하게 값 변경 가능: AtomicInteger는 멀티 스레드 환경에서 발생할 수 있는 경쟁 조건(race condition) 문제를 해결하여 안전하게 값을 변경할 수 있도록 보장한다.
Atomic 클래스의 다양한 기능 활용 가능: AtomicInteger는 getAndIncrement, getAndUpdate 등 다양한 메서드를 제공하여 원자적인 연산을 수행할 수 있도록 지원한다.
단점
Atomic 클래스 사용에 대한 이해 필요: AtomicInteger 클래스를 사용하려면 Atomic 클래스에 대한 기본적인 이해가 필요하다.
박싱/언박싱 오버헤드 발생 가능: AtomicInteger는 내부적으로 int 값을 객체로 감싸기 때문에, 박싱/언박싱 오버헤드가 발생할 수 있다. 하지만 대부분의 경우 성능에 큰 영향을 미치지 않는다.
마무리
지금까지 자바 람다 표현식에서 발생하는 "local variables referenced from a lambda expression must be final or effectively final" 에러의 원인과 해결 방법 세 가지를 살펴보았다.
람다 표현식은 자바 코드를 간결하고 효율적으로 작성하는 데 유용하지만, 외부 변수를 사용할 때 주의해야 할 점이 있다. 특히 람다 캡처링과 변수 범위를 이해하고, final 또는 effectively final 조건을 준수해야 한다.
이번 포스트에서 소개한 세 가지 해결 방법은 각각 장단점을 가지고 있으므로, 상황에 맞는 방법을 선택하여 적용하면 된다. 특히 멀티 스레드 환경에서는 AtomicInteger와 같은 Atomic 클래스들을 사용하는 것이 가장 안전하고 효율적인 방법이다.
이 글이 람다 표현식을 사용하는 데 어려움을 겪는 개발자들에게 도움이 되었기를 바란다.
02024. 07. 03.
Java
문자열 해시를 쓰면 안되는 이유 (HashMap, HashSet)
얼마 전, 아는 동생이 코테 대비 문제를 푸는데 똑같아야 할 것 같은 두 코드의 실행 시간이 너무 차이 난다는 이야기를 했다.
좌표를 다루어야 하는 문제인데, 한 코드는 다음과 같이 좌표를 int를 변환시켜 HashMap에 적용하였다.
Map<Integer, Integer> map = new HashMap<>();
while (/* 반복 */) {
int x = /* x 좌표 */;
int y = /* y 좌표 */;
int key = y * HEIGHT + x;
map.put(map.getOrDefault(key, 0) + 1);
}
또 다른 코드는 좌표를 String으로 변환하여 적용하였다.
Map<String, Integer> map = new HashMap<>();
while (/* 반복 */) {
int x = /* x 좌표 */;
int y = /* y 좌표 */;
String key = String.format("%d, %d", x, y);
map.put(map.getOrDefault(key, 0) + 1);
}
위 코드의 실행 시간은 0.3초, 아래 코드의 실행 시간은 1.8초로 어마어마한 성능 차이를 보였다.
어디서 이런 차이가 발생하는 걸까?
HashMap, HashSet 이해하기
많은 사람들이 HashMap이나 HashSet과 같이 해시라는 이름이 들어간 자료구조는 연산은 상수 시간 O(1)만에 한다고 생각하는 경향이 있다.
아니라는 것을 인지하고 있어도, 대부분 해시 충돌에 대해서만 신경쓴다.
하지만 HashMap과 HashSet을 사용할 때 가장 먼저 생각해 봐야 할 것은 해시 충돌이 아니다.
바로 키의 해시 코드를 어떻게 구하는 가이다.
이에 따라서 이 자료들에 대해서 알고 있는 시간 복잡도가 크게 바뀔 수 있기 때문이다.
연산 시간 복잡도
이 자료구조들은 내부적으로 키의 해시 코드를 이용하여 삽입, 삭제, 검색 등 연산을 수행한다.
이 연산들의 시간 복잡도는 일반적으로 O(1)을 기대할 수 있다.
문제는 해시 코드를 구할 때의 시간 복잡도이다.
해시를 활용하는 자료구조들은 자바의 Object 클래스에 선언된 hashCode() 메서드를 사용하여 해시를 구한다.
즉, hashCode()의 시간 복잡도가 이 자료구조들의 연산에 대한 시간 복잡도를 결정짓는 중요한 요소가 되는 것이다.
Integer::hashCode
위에서 첫 번째 코드는 키로 Integer를 사용하였다.
Integer의 hashCode()는 그 값 자체를 해시 값으로 사용할 수 있을 것 같다.
실제로 구글에 "java Integer implementation"이라고 검색하여 깃헙에 올라와있는 구현체를 살펴보면 다음과 같다.
@Override
public int hashCode() {
return Integer.hashCode(value);
}
public static int hashCode(int value) {
return value;
}
예상한 대로, 가지고 있는 값을 그대로 반환하여 상수 시간 안에 처리하게 된다.
이러한 경우 해시 충돌을 제외한다면 해시를 활용한 연산에 O(1)의 시간 복잡도를 기대할 수 있다.
String::hashCode
그렇다면 String의 시간 복잡도는 어떻게 될까?
직관적으로 생각해 보자.
문자열에 대한 해시 값을 생성하려면 문자열에 있는 모든 문자들을 순회하며 어떠한 처리를 해야 할 것 같다.
여기서부터 문자열의 hashCode()는 문자열의 길이 N에 비례하는 O(N)의 시간 복잡도가 걸릴 것 같다는 것을 의심할 수 있다.
구글에 "java String implementation"을 검색하여 이를 확인해 보자.
public int hashCode() {
int h = hash;
if (h == 0 && !hashIsZero) {
h = isLatin1() ? StringLatin1.hashCode(value)
: StringUTF16.hashCode(value);
if (h == 0) {
hashIsZero = true;
} else {
hash = h;
}
}
return h;
}
:: < String::hashCode 정의 > ::
라틴인지 여부에 따라 분기가 되어 다른 메서드로 해시 코드를 구한다.
더 일반적일 StringUTF16.hashCode()부터 쭉 따라가 보자.
public static int hashCode(byte[] value) {
return ArraysSupport.hashCodeOfUTF16(value, 0, value.length >> 1, 0);
}
:: < StringUtf16::hashCode 정의 > ::
public static int hashCodeOfUTF16(byte[] a, int fromIndex, int length, int initialValue) {
return switch (length) {
case 0 -> initialValue;
case 1 -> 31 * initialValue + JLA.getUTF16Char(a, fromIndex);
default -> vectorizedHashCode(a, fromIndex, length, initialValue, T_CHAR);
};
}
:: < ArraysSupport::hashCodeOfUTF16 정의 > ::
@IntrinsicCandidate
private static int vectorizedHashCode(Object array, int fromIndex, int length, int initialValue,
int basicType) {
return switch (basicType) {
case T_BOOLEAN -> unsignedHashCode(initialValue, (byte[]) array, fromIndex, length);
case T_CHAR -> array instanceof byte[]
? utf16hashCode(initialValue, (byte[]) array, fromIndex, length)
: hashCode(initialValue, (char[]) array, fromIndex, length);
case T_BYTE -> hashCode(initialValue, (byte[]) array, fromIndex, length);
case T_SHORT -> hashCode(initialValue, (short[]) array, fromIndex, length);
case T_INT -> hashCode(initialValue, (int[]) array, fromIndex, length);
default -> throw new IllegalArgumentException("unrecognized basic type: " + basicType);
};
}
:: < ArraysSupport::vectorizedHashCode 정의> ::
private static int utf16hashCode(int result, byte[] value, int fromIndex, int length) {
int end = fromIndex + length;
for (int i = fromIndex; i < end; i++) {
result = 31 * result + JLA.getUTF16Char(value, i);
}
return result;
}
:: < ArraysSupport::utf16hashCode 정의> ::
드디어 O(N)이 걸리는 부분이 등장했다.
utf16hashCode() 메서드는 fromIndex부터 fromIndex + length까지 반복을 돌며 O(length)만큼의 시간 복잡도를 갖는다.
처음부터 따라가 보면 이 length는 문자열의 길이가 되므로 String의 hashCode() 메서드는 O(N)이 걸리는 것을 실제로 확인할 수 있는 셈이다.
마무리
이렇게 해시 자료구조라고 하더라도 사용하는 자료형에 따라 상수 시간이 걸리지 않음을 확인할 수 있었다.
문제를 풀기 전 반드시 시간 복잡도를 계산하는 시간을 가져야 한다.
이때 해시를 구하는 시간 복잡도를 빼먹지 않도록 주의하자.
02024. 06. 26.
Java
Getter / Setter 제대로 사용하기
자바에서 접근 제어자를 처음 배울 때 Getter와 Setter에 대해서도 같이 배우게 된다.
하지만 getter와 setter를 잘못 설명하고 있는 경우가 많은 것 같아 이들을 어떤 경우에 사용해야 하는지에 대해 적어보고자 한다.
잘못된 예제
많은 예제들이 캡슐화라고 하면서 멤버 필드를 private으로 만들고, getter, setter 메서드를 통해 해당 필드에 접근하게 한다.
public class Meaningless {
private int value = 0;
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
}
위의 코드는 멤버 필드를 public으로 두는 것과 다를 바 없다. 외부에서는 해당 값을 그대로 읽고, 값을 덮어쓸 수 있다.
public class Same {
public int value = 0;
}
오히려 메서드 호출 오버헤드가 발생하고 불필요한 코드가 생기는 셈이니 더 나쁘다고도 할 수 있다.
Getter와 setter는 메서드로서의 역할을 다 할 때 그 의미가 생긴다.
외부에서 값을 덮어쓰는 것을 막을 때
게임에서 점수 시스템을 만든다고 해보자.
코인을 획득하면 1점, 적을 처치하면 5점이 오른다.
이를 관리하는 Score 클래스는 다음과 같이 작성할 수 있다.
public class Score {
private int score = 0;
public int getScore() {
return score;
}
public void coinEarned() {
score += 1;
}
public void enemyKilled() {
score += 5;
}
}
score는 클래스 내부에 숨겨두고, score를 증가시킬 수 있는 메서드만 외부에 공개함으로써 score를 직접적으로 변경하지 못하게 차단하였다. 이를 통해 score는 이 클래스 내부에서만 관리한다는 제약을 걸어, 외부에서 예상하지 못하게 값을 수정하는 일에 대해 걱정하지 않아도 되게 되었다.
값을 제한할 때
특정 범위 내의 값을 관리한다고 생각해보자. 이 클래스는 다음과 같이 작성할 수 있다.
public class ClampedValue {
public final int min;
public final int max;
private int value = 0;
public ClampedValue(int min, int max) {
this.min = min;
this.max = max;
}
public int getValue() {
return value;
}
public void setValue(int value) {
if (value < min) {
this.value = min;
} else if (value > max) {
this.value = max;
} else {
this.value = value;
}
}
}
한 멤버 필드에 대해서 getter와 setter가 있지만, setter에서 해당 필드의 값을 제한하는 처리를 한다.
객체 필드일 때
필드가 객체인 경우, 그리고 해당 객체가 불변 객체가 아닐 경우 이를 외부에 그대로 노출하는 것은 매우 위험하다.
이를 해결하기 위해서 getter에서 객체를 복사하여 넘겨줄 수 있다.
숫자들의 합을 구하면서 그 과정을 기록하는 RecordedSum 클래스를 살펴보자.
public class RecordedSum {
private final List<Integer> history = new ArrayList<>();
private int sum = 0;
public List<Integer> getHistory() {
return Collections.unmodifiableList(history);
}
public int getSum() {
return sum;
}
public void add(int value) {
history.add(value);
sum += value;
}
}
이 예제에서는 List인 history를 외부에서 요구할 때 불변 리스트로 변환하여 반환한다.
이렇게 함으로써 외부에서 history를 함부로 수정할 수 없게 하여 history는 RecordedSum 클래스 내부에서만 관리할 수 있도록 할 수 있다.
이렇게 getter와 setter가 의미 있게 사용될 수 있는 경우들을 살펴보았다.
public 멤버가 나쁜 것이 아니다.
해당 멤버의 값이 변함으로써 발생하는 사이드 이펙트가 없고, 외부에서 자유롭게 변경하도록 의도된 것이라면 public으로 공개해주는 것이 좋은 설계가 될 수 있다.
public이 될 수 있는 멤버를 굳이 private으로 숨기면서 의미 없는 getter와 setter를 사용하는 일이 없도록 하자.
02024. 06. 14.
Java
자바로 분수 나타내기
프로그래머스 문제 중 Lv 2짜리 아날로그 시계라는 문제를 봤다.
주어진 시간 범위 내에 초침이 시침, 분침과 겹치는 횟수를 세는 문제인데,
이 횟수를 세는 것은 그렇게 어렵지 않았지만 난관은 시침, 분침, 초침이 모두 겹칠 때에는 한 번으로 세어야 한다는 점이었다.
이 부분에서 분수를 나타낼 필요성을 느껴 분수를 다루는 클래스를 작성해 보았다.
생성자
가장 먼저, 분수에 있어서 필요한 분자와 분모를 정수형으로 선언해 주었다.
값을 나타내는 용도의 클래스인 만큼, final을 붙여 불변성을 부여하였다.
final로 선언된 원시 자료형이기 때문에, 이 두 값은 클래스 내부에서 관리함에도 불구하고 public으로 선언될 수 있다.
외부에서 자유롭게 참조는 가능하지만, final이 붙었기 때문에 값을 변경할 수 없기 때문이다.
public class Fraction {
public final long numerator;
public final long denominator;
}
가장 베이스가 되는 생성자로는 이 두 값을 입력받는 생성자를 정의하였다.
이때, 최대 공약수로 numerator와 denomiator를 나누어 주어 약분까지 해주었다.
public Fraction(long numerator, long denominator) {
long gcd = gcd(Math.abs(numerator), Math.abs(denominator));
this.numerator = numerator / gcd;
this.denominator = denominator / gcd;
}
private static long gcd(long a, long b) {
if (b == 0) return a;
return gcd(b, a % b);
}
그리고 조금 더 편하게 사용할 수 있도록 디폴트 생성자와 정수를 입력받는 생성자를 오버로딩 하였다.
public Fraction() {
this(0);
}
public Fraction(long value) {
this(value, 1);
}
덧셈, 뺄셈, 곱셈
덧셈, 뺄셈, 곱셈은 각 연산의 정의대로 구현하였다.
이 때, 새로운 Fraction 객체를 생성하여 반환함으로써 불변성을 유지하였다.
또, 생성자에서 약분을 하므로, 모든 Fraction 객체는 기약 분수 상태를 항상 확보하게 된다.
public Fraction add(Fraction other) {
long numerator = this.numerator * other.denominator + this.denominator * other.numerator;
long denominator = this.denominator * other.denominator;
return new Fraction(numerator, denominator);
}
public Fraction subtract(Fraction other) {
long numerator = this.numerator * other.denominator - this.denominator * other.numerator;
long denominator = this.denominator * other.denominator;
return new Fraction(numerator, denominator);
}
public Fraction multiply(Fraction other) {
long numerator = this.numerator * other.numerator;
long denominator = this.denominator * other.denominator;
return new Fraction(numerator, denominator);
역수, 나눗셈
나눗셈의 경우 그대로 구현할 수 도 있지만, 역수를 구한 후 곱셈으로 처리하는 것이 구현 상 간단하다.
다만, 분수가 0인 경우 역수를 구할 수 없으므로 이런 경우에는 Exception을 발생시켜 준다.
수학 연산 중 발생한 Exception이니 ArithmeticException을 발생시킨다.
public Fraction inverse() {
if (numerator == 0) {
throw new ArithmeticException("Cannot inverse zero.");
}
return new Fraction(denominator, numerator);
}
나눗셈은 이를 이용하여 역수를 구해, 곱해주기만 하면 된다.
public Fraction divide(Fraction other) {
return multiply(other.inverse());
}
근사치
분수가 나타내는 근삿값을 알기 위한 메서드도 추가해 준다.
public double value() {
return (double) numerator / denominator;
}
Object 메서드
값을 나타내는 클래스이니, 동등 비교와 해시 등의 메서드 오버라이딩을 해주어야 한다.
이를 해주지 않으면, Collection 컨테이너들을 사용할 때 해당 컨테이너들이 제 힘을 발휘하지 못하게 됨은 물론, 두 분수를 비교할 때나 그 값을 확인할 때에도 불편해진다.
@Override
public String toString() {
return String.format("%d / %d", numerator, denominator);
}
@Override
public boolean equals(Object object) {
if (this == object) return true;
if (object == null || getClass() != object.getClass()) return false;
Fraction other = (Fraction) object;
return numerator == other.numerator && denominator == other.denominator;
}
@Override
public int hashCode() {
return Objects.hash(numerator, denominator);
}
전체 코드
import java.util.Objects;
public class Fraction implements Comparable<Fraction> {
public final long numerator;
public final long denominator;
public Fraction() {
this(0);
}
public Fraction(int value) {
this(value, 1);
}
public Fraction(long numerator, long denominator) {
long gcd = gcd(Math.abs(numerator), Math.abs(denominator));
this.numerator = numerator / gcd;
this.denominator = denominator / gcd;
}
public Fraction add(int value) {
return new Fraction(numerator + value * denominator, denominator);
}
public Fraction add(Fraction other) {
long numerator = this.numerator * other.denominator + this.denominator * other.numerator;
long denominator = this.denominator * other.denominator;
return new Fraction(numerator, denominator);
}
public Fraction subtract(Fraction other) {
long numerator = this.numerator * other.denominator - this.denominator * other.numerator;
long denominator = this.denominator * other.denominator;
return new Fraction(numerator, denominator);
}
public Fraction multiply(int multiplier) {
return new Fraction(numerator * multiplier, denominator);
}
public Fraction multiply(Fraction other) {
long numerator = this.numerator * other.numerator;
long denominator = this.denominator * other.denominator;
return new Fraction(numerator, denominator);
}
public Fraction divide(Fraction other) {
return multiply(other.inverse());
}
public Fraction inverse() {
if (numerator == 0) {
throw new ArithmeticException("Cannot inverse zero.");
}
return new Fraction(denominator, numerator);
}
public Fraction mod(int value) {
return new Fraction(numerator % (value * denominator), denominator);
}
public double value() {
return (double) numerator / denominator;
}
@Override
public String toString() {
return String.format("%d / %d", numerator, denominator);
}
@Override
public boolean equals(Object object) {
if (this == object) return true;
if (object == null || getClass() != object.getClass()) return false;
Fraction other = (Fraction) object;
return numerator == other.numerator && denominator == other.denominator;
}
@Override
public int hashCode() {
return Objects.hash(numerator, denominator);
}
@Override
public int compareTo(Fraction f) {
if (equals(f)) {
return 0;
} else if (value() < f.value()) {
return -1;
} else {
return 1;
}
}
private static long gcd(long a, long b) {
if (b == 0) return a;
return gcd(b, a % b);
}
}
02024. 06. 09.