[Immutable Vs Mutable]
프로그램의 오류는 예기치 못한 동작으로 인해 발생하게 됩니다.
이 예기치못한 동작을 줄이고 예측 가능하게 동작시키려면 어떻게 설계해야 할까요??
자바는 생성된 이후 상태를 변경할 수 있는 Mutable Type과 그렇지 않은 Immutable Type이 존재합니다.
지금부터 각 타입의 특징과 장단점에 대해 알아보겠습니다.
Immutable Type (불변 타입)
Immutable Type은 생성된 후 상태를 유지하는 타입을 의미합니다.
대표적인 예시로 String 이 존재합니다.
String Class
value , coder 등등의 field가 final로 선언된 것을 볼 수 있습니다.
이를 통해 String Type을 불변객체로 만드는 것이죠
하지만 우리는 한가지 의문에 빠집니다.
값이 안바껴?? += 연산으로 값 바뀌던데??
String str="1234";
str+="5678";
불변객체 임에도 다음의 연산이 가능한 이유는 "1234" 값을 갖고있는 str의 값이 변하는게 아닌
"12345678" 값을 갖고있는 새로운 str 객체를 만들어 내는 것이기 떄문입니다.
사실상 아래와 같은 연산을 하는 것이죠
String str = new String("12345678");
(이러한 연산또한 막고싶다면 final String 으로 선언하면 됩니다)
Constant Value
static final int MAX_SIZE = 10;
상수또한 불변타입에 속합니다.
불변타입이기 떄문에 값을 변경하려고 시도하면 에러가 발생하는 것을 볼 수 있습니다.
MAX_SIZE=20; // 에러발생
불변타입을 사용한다면 상태변화를 막을수 있기 때문에 객체가 초기에 설정한 상태를 유지하게 되고
상태변화로 인한 버그를 막을 수 있게 됩니다.
또한 다른 메서드의 파라미터로 전달될 떄에도 다른 코드에 의해 수정될 염려가 없기 떄문에 안전하게 전달가능합니다.
뿐만 아니라 불변값임을 명시함으로서 더욱 이해하기 쉬운 코드를 작성할 수 있습니다.
public static int rangeSum(final int start, final int end) {
int result = 0;
for (int i = start; i <= end; i++) {
result += i;
}
return result;
}
시작지점부터 끝나는 지점까지의 정수합을 반환하는 메서드입니다.
시작지점과 끝나는 지점을 불변하게 만듦으로서 이해를 돕고 버그를 방지합니다.
불변타입은 변경할 수 없기 떄문에 호환성이 떨어지고 코드의 작성을 어렵게 만들지만 그로인한 버그또한 줄여줍니다.
따라서 견고한 프로그램을 작성하고 싶다면 불변타입을 사용하는 것이 좋습니다.
Mutable Type(가변 타입)
StringBuilder는 String과 비슷한 기능을 하지만 가변객체 입니다.
StringBuilder sb = new StringBuilder();
sb.append("1234");
sb.append("5678");
System.out.println(sb);
이 코드는 실제로 sb가 가지고 있는 값이 변경되는 것이죠
String str = sb.toString();
System.out.println(str);
StringBuilder는 toString을 통해서 String Type으로도 사용가능하고
객체를 새로 만들어 내는 것이 아니기 떄문에 성능측면에서도 String보다 뛰어납니다.
하지만 이러한 호환성이 견고한 프로그램 작성을 어렵게 만듭니다.
Risks Of Mutaion (가변위험성)
Mutable Type은 위에서 명시한 것 처럼 장점이 많습니다.
상태 변경이 가능하기 떄문에 호환성이 뛰어나며 이로인해 쉬운 코드를 작성할 수 있는것이죠
하지만 똑같은 이유로 단점이됩니다.
상태 변경이 가능하기 떄문에 프로그램 시작에서의 상태와 프로그램의 특정 시점에서의 상태가 다를 수 있기 떄문이죠
따라서 객체가 어떤 상태인지 이해하기 어려워지고 Contract를 강제하는데 어려움이 생깁니다.
예시를 통해 알아보겠습니다.
Passing Mutable values
public static int sum(List<Integer> list) {
int sum = 0;
for (int num : list) {
sum += num;
}
return sum;
}
List요소의 합을 반환하는 메서드
public static int sumAbsolute(List<Integer> list) {
for (int i = 0; i < list.size(); i++) {
list.set(i, Math.abs(list.get(i)));
}
return sum(list);
}
List 요소의 절대값 합을 반환하는 메서드
Mutable Type을 인자로 받았기 떄문에 상태변경이 가능합니다.
또한 sumAbsolute 메서드는 기존에 작성한 sum메서드를 재사용하기 떄문에 효율적으로 보이기까지 합니다.
하지만 이 코드는 치명적인 버그를 발생시킬 것입니다.
sumAbsolute() 메서드를 호출한 후에는 List요소 값이 변하기 떄문입니다.
List<Integer> list = new ArrayList<>(Arrays.asList(1,2,3,-1,-2,-3));
System.out.println(sum(list));
System.out.println(sumAbsolute(list));
System.out.println(sum(list));
이 코드의 결과 값은 다음과 같습니다.
list가 가변객체이기 때문에 처음 정의한 list와 sumAbsolute를 호출한 뒤의 list의 정의가 달라집니다.
이런식으로 재사용이나 성능에서 장점을 가질 수 있지만 추적하기 어려운 버그를 일으킬 수 있습니다.
Aliasing Risk
Alias(동일한 참조를 갖는 변수)가 존재할때 문제가 생길 수 있습니다.
List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, -1, -2, -3));
List<Integer> list2 = list;
list2.add(5);
list2.add(6);
list2.add(7);
System.out.println(sum(list) + " " + sum(list2));
list2는 list와 동일한 참조를 갖기 떄문에 list2의 상태변화가 list에게도 영향을 주어 버그를 일으킬 수 있습니다.
Mutation undermines an iterator
public static void removeUnderZero(List<Integer> list) {
for (int i = 0; i < list.size(); i++) {
if (list.get(i) < 0) {
list.remove(i);
}
}
}
0보다 작은 요소를 삭제하는 메서드입니다.
문제될 것이 없어보이지만 list가 가변적으로 변함으로서 요소가 삭제될시 size() 또한 가변적으로 변하게 됩니다.
따라서 결과값이 예상과 다르게 반환됩니다.
Mutable objects reduce changeability
가변객체의 사용은 코드를 변경하기 어렵게 만듭니다.
list의 sum에 대해서 불필요한 연산을 막고자 cache를 도입하는 상황을 가정하겠습니다.
public static Map<List<Integer>, Integer> cache = new HashMap<>();
public static void main(String[] args) {
List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3));
addToCache(list);
System.out.println(cache.get(list));
}
public static void addToCache(List<Integer> list) {
if (cache.get(list) == null) {
cache.put(list, sum(list));
}
}
문제없이 동작하는 것을 볼 수 있습니다.
하지만 코드가 추가된다면 어떨까요??
list.add(4);
list.add(5);
System.out.println(cache.get(list));
list의 상태가 변경되면서 cache가 정상적으로 동작하지 않게됩니다.
이렇게 가변객체는 논리적인 버그를 발생시킬 수 있습니다. 따라서 보다 안전한 방법으로 사용되야 합니다.
Use Producer
Producer는 기존객체에서 새로운 객체를 생성하는 것을 의미합니다.
public static void sortList(List<Integer> list) {
for (int i = 0; i < list.size(); i++) {
for (int j = 0; j < list.size(); j++) {
if (list.get(i) < list.get(j)) {
int temp = list.get(i);
list.set(i, list.get(j));
list.set(j, temp);
}
}
}
}
다음은 list를 오름차순으로 정렬하는 메서드입니다.
이 연산은 문제없이 동작하지만 list의 요소를 변화시킵니다. 이로인해 생기는 버그를 막기 위해 메서드를 변경하겠습니다.
public static List<Integer> sortList(List<Integer> list) {
List<Integer> result = new ArrayList<>(list);
for (int i = 0; i < result.size(); i++) {
for (int j = 0; j < result.size(); j++) {
if (result.get(i) < result.get(j)) {
int temp = result.get(i);
result.set(i, result.get(j));
result.set(j, temp);
}
}
}
return result;
}
새로운 List 객체를 반환하도록 만듦으로서 기존 list를 변화시키지 않아 버그발생을 줄일 수 있습니다.
(이런 방식을Defensive Copy라 합니다.)
Localize Value
변수가 사용되는 범위를 줄여야 합니다. 가변객체가 넓은 범위에서 사용될 수 있다면 (Static 이거나 메서드 자체가 거대하다면)
상태변화로 생기는 버그를 잡기 어려울 것입니다.
이 글에서는 Immutable Type 과 Mutable Type의 이해와 장단점에 대해 다뤘습니다.
도움이 되셨다면 좋겠습니다.
'프로그래밍 기초 > 전산학 기초' 카테고리의 다른 글
Specification 기본 개념 (2) | 2023.10.23 |
---|---|
Collection FrameWork 자료구조의 이해 (1) | 2023.10.23 |
[추상클래스 Vs 인터페이스] (0) | 2023.10.22 |
클래스 설계와 Abstraction Barrier (0) | 2023.10.22 |
Abstraction(추상화) 기본 개념 - 1편 (0) | 2023.10.20 |