Java의 final은 불변성을 보장해주지 않는다.
면접 스터디를 준비하며 Java의 불변 객체에 대해서 학습한 내용을 남겨 놓으려 한다.
final 키워드는 무엇일까?
final 키워드는 클래스, 메서드, 변수, 에 사용되는데 어디에 붙이느냐에 따라 용도가 조금씩 달라진다.
특히 변수나 객체의 필드에서 final 키워드는 조금 특별한 의미를 가지게 된다.
클래스
클래스 앞에 final 키워드를 붙인다면 해당 클래스를 상속하는 것이 불가능하다.
// 상위 클래스
public final class ParentClass {
private int age;
}
// 하위 클래스 (불가능)
public class ChildClass extends ParentClass{
public int age;
}
메서드
메서드 앞에 final 키워드를 붙인다면 해당 메서드의 오버라이딩이 불가능하다.
이는 하위 클래스가 상위 클래스를 상속하여 확장하는 것을 방지한다.
// 상위 클래스
public class ParentClass {
private int age;
public final int plusAge() {
return this.age + 1;
}
}
// 하위 클래스
public class ChildClass extends ParentClass{
public int age;
// 상위 클래스의 메서드를 오버라이딩 하지 못함
@Override
public int plusAge() {
return super.plusAge();
}
}
변수
기본타입의 변수 앞에 final 키워드를 붙인다면 해당 변수는 값을 한 번만 할당할 수 있다.
이것은 상수처리되어 불변하다고 말할 수 있다.
public class Application {
public static void main(String[] args) {
final int COUNT = 7;
// 재할당 불가능
COUNT = 2;
}
}
그렇다면 변수가 참조 타입인 경우에는 어떨까?
public final class Books {
public final List<String> bookList = new ArrayList<>();
}
Books 클래스의 bookList 필드는 final 키워드가 붙어서 마치 불변인 것처럼 보인다.
그러나 몇 가지 메서드를 추가하고 실행해 보면 불변이 아닌 것을 알 수 있다.
public final class Books {
public final List<String> bookList = new ArrayList<>();
// 내부 상태를 변경하는 메서드
public void addBook(String name) {
bookList.add(name);
}
// 내부의 상태를 노출하는 메서드
public List<String> getBookList() {
return bookList;
}
}
public class application {
public static void main(String[] args) {
Books books = new Books();
books.addBook("Object");
System.out.println("북 리스트 : " + books.getBookList());
List<String> bookList2 = books.getBookList();
bookList2.add("EffectiveJava");
System.out.println("북 리스트 : " + books.getBookList());
}
}
---------------실행 결과---------------
북 리스트 : [Object]
북 리스트 : [Object, EffectiveJava]
Books 클래스에 내부의 상태를 변경하는 add 메서드와, 내부의 상태를 노출하는 get 메서드를 추가했다.
main 클래스에서 첫 번째 책인 "Object"를 추가했다.
그리고 bookList를 가져와 bookList2를 만들고 bookList2에 "EffectiveJava"를 추가했다.
만약 Books 클래스의 bookList가 불변이라면 위 메서드가 수행되어도 bookList는 비어있어야 하지만
2개의 책이 정상적으로 추가되었다.
이는 final 키워드가 참조의 불변성만을 의미하기 때문이다.
final 키워드는 한 번 초기화된 참조 변수가 다른 객체를 가리키지 못하게 고정하는 데 사용된다.
bookList는 한 번만 ArrayList 객체에 연결되며, 이후에는 재할당 할 수 없다.
그러나 final이 참조의 불변성만을 강제할 뿐, bookList가 가리키는 ArrayList의 내부 상태는 가변적이므로, 해당 컬렉션의 요소들은 변경될 수 있다.
public final class Books {
public final List<String> bookList = new ArrayList<>();
// 내부 상태를 변경하는 메서드
public void addBook(String name) {
bookList.add(name);
}
// 내부의 상태를 노출하는 메서드
public List<String> getBookList() {
return bookList;
}
public void changeReference() {
// 참조 재할당 불가능
bookList = new LinkedList<>();
}
}
이와 같이 참조타입의 변수에서는 final 키워드만으로 참조하는 객체의 불변성을 보장하지 않는다는 것을 알 수 있다.
불변 객체를 만들기 위해서는?
그렇다면 참조타입의 변수에서 참조하는 객체를 불변으로 만들기 위해서는 어떻게 해야 할까?
필드 캡슐화, 방어적 복사
먼저 필드의 접근 제어자를 public에서 private로 변경한다.
필드의 상태를 변경하는 setter 메서드가 없지만 보다 완벽한 불변성을 유지하기 위해서이다.
또한, 객체의 내부 상태를 노출하거나 외부에서 전달받을 경우 복사본을 만들어 사용하는 방어적 복사를 사용한다.
public final class Books {
private final List<String> bookList;
// 생성자를 통해 참조 객체 설정
public Books(List<String> initBookList) {
this.bookList = new ArrayList<>(initBookList);
}
// 외부에 복사된 객체 반환
public List<String> getBookList() {
return new ArrayList<>(bookList);
}
// 책을 추가하고 싶다면 새로운 객체 반환
public Books addBook(String name) {
List<String> newBookList = new ArrayList<>(bookList);
newBookList.add(name);
return new Books(newBookList);
}
}
public class Application {
public static void main(String[] args) {
List<String> initList = new ArrayList<>();
initList.add("beforeInit");
Books books = new Books(initList);
System.out.println("북 리스트 : " + books.getBookList());
initList.add("afterInit");
System.out.println("북 리스트 : " + books.getBookList());
List<String> bookList2 = books.getBookList();
bookList2.add("effectiveJava");
System.out.println("북 리스트 : " + books.getBookList());
Books books2 = books.addBook("modernJavaInAction");
System.out.println("북 리스트2 : " + books2.getBookList());
}
}
---------------실행 결과---------------
북 리스트 : [beforeInit]
북 리스트 : [beforeInit]
북 리스트 : [beforeInit]
북 리스트2 : [beforeInit, modernJavaInAction]
수정된 Books 클래스는 생성자 파라미터로 받은 initBookList를 복사하여 bookList 필드를 가지게 된다.
따라서 이후에 initList에 "afterInit"을 추가하여도 bookList 필드는 불변성을 유지한다.
또한, getBookList 메서드가 호출되었을 때도 bookList 필드의 복사본을 반환하여
bookList2에서 일어난 "effectiveJava"의 수정은 bookList에 반영되지 않기 때문에 불변성을 유지하게 된다.
만약 생성한 books를 변경하고 싶다면 addBook 메서드를 호출하여 새로운 객체를 반환하면 된다.
다만, getBookList와 같이 방어적 복사를 적용한 메서드가 많이 호출될 경우 호출 때마다
새로운 인스턴스를 생성하여 반환하게 되므로 비효율적인 메모리 사용이 될 수 있다는 것을 염두에 둬야 한다.