[Effective Java] Item 13. clone 재정의는 주의해서 진행하라 (Cloneable, Object.clone 알아보기)
Object.clone 메소드 알아보기
Object.clone: Cloneable 인터페이스의 구현 클래스 인스턴스들을 복사할 때 사용하는 메소드입니다.
Object.clone 메서드의 선언부는 다음과 같습니다.
public class Object {
...
protected native Object clone() throws CloneNotSupportedException { ... }
...
}
clone을 호출하면 복사 대상 객체의 필드들을 하나하나 복사한 객체를 반환합니다. 만일 Cloneable 인터페이스를 상속하지 않은 클래스에서 clone을 호출하면 CloneNotSupportedException 예외를 던집니다.
Java의 Object 클래스 명세서에서는 clone 메서드의 복사 방식에 대해 다음과 같이 설명하고 있습니다.
어떤 객체 x에 대해 다음 식은 참이다.
x.clone() != x
x.clone().getClass() == x.getClass()
한편 다음 식도 일반적으로 참이지만, 필수는 아니다.
x.clone().equals(x)
관례상, 이 clone 메서드가 반환하는 객체는 super.clone을 호출해 얻어야 한다.
이 클래스와 모든 상위 클래스가 이 관례를 따른다면 다음 식은 참이다.
x.clone().getClass() == x.getClass()
관례상, 반환된 객체와 원본 객체는 독립적이어야 한다.
이를 만족하려면 super.clone으로 얻은 객체의 필드 중 하나 이상을 반환 전에 수정해야 할 수도 있다.
이펙티브 자바의 저자는 책을 통해 이 clone 메서드에 대한 규약이 매우 허술하다는 점을 지적합니다.
1. clone 메서드가 super.clone가 아닌 생성자 호출로 인스턴스를 반환하도록 구현해도 컴파일러가 이 부분을 지적하지 않습니다.
2. 생성자 호출로 구현했을 때, 하위 클래스에서 이 메소드를 super.clone으로 호출한다면 잘못된 객체가 만들어질 수 있습니다.
Cloneable 인터페이스
Object.clone 메서드로 어떤 클래스의 인스턴스들을 복사하고 싶다면 그 클래스는 Cloneable 인터페이스를 구현해야 합니다. Cloneable 인터페이스는 다음과 같이 아무 것도 선언되어 있지 않습니다.
public interface Cloneable {}
여기서 주목해야 할 점은 clone 메서드가 선언된 곳이 Cloneable이 아닌 Object이며 메서드가 protected라는 점입니다. Cloneable을 단순히 구현하는 것만으로는 복사할 객체의 외부에서 clone 메서드를 호출할 수는 없고 객체 안에서 super 키워드를 통해 접근해야만 합니다. 아래에서 아주 간단한 clone 메서드 사용 예제를 살펴보겠습니다.
불변 클래스에서 clone 메서드 제공하기
public class Bag implements Cloneable {
private int color;
private int size;
@Override
public Bag clone() {
try {
return (Bag) super.clone();
} catch (ClassNotSupportedException e) {
throws AssertionError; // 일어날 수 없는 일
}
}
}
Cloneable 인터페이스를 상속받은 클래스는 Object.clone 메서드를 재정의하여 복사 방식을 결정해야 합니다. 구현 클래스에서는 clone 메서드를 public으로 제공해 외부에서 사용할 수 있도록 열어줍니다. 이렇게 구현하는 경우, 외부에서는 clone 메서드를 통해 생성자를 호출하지 않고도 객체를 생성할 수 있게 됩니다.
공변 반환 타이핑
부모 클래스의 메소드를 오버라이딩하는 경우에는 부모 클래스의 반환 타입을 자식 클래스 타입으로 변환하는 것을 권장합니다. 이 변환 과정을 공변 반환 타이핑이라고 부릅니다. 위 코드에서는 super.clone을 호출해 얻은 원본의 완벽한 복제본을 Bag 타입으로 바꿔 반환하고 있습니다.
super.clone 호출을 try-catch 블록으로 감싼 이유
Object.clone 메서드는 복사할 클래스가 Cloneable 인터페이스를 구현하지 않은 경우 CloneNotSupportedException을 던지도록 선언되어 있습니다. 그렇기 때문에 Bag 클래스는 Cloneable의 구현체임에도 불구하고 try-catch 예외 처리 블록을 반드시 작성해야만 합니다. 사실 Bag과 같은 불변 클래스는 쓸데없는 복사를 지양한다는 관점에서 보면 clone 메서드를 제공하지 않는 것이 좋습니다.
가변 객체를 참조하는 클래스에서 clone 메서드 제공하기
간단했던 clone 구현 방법은 클래스가 가변 객체를 참조하는 순간 매우 복잡해지기 시작합니다.
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
this.elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
ensureCapacity();
elements[size++] = e;
}
public Object pop() {
if (size == 0) throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null;
return result;
}
// 원소를 위한 공간을 적어도 하나 이상 확보한다.
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
위에서 구현한 Stack 클래스를 복제할 수 있도록 만들어보겠습니다. Stack의 clone 메서드가 불변 클래스처럼 super.clone 결과를 그대로 반환한다면 elements 필드는 원본 Stack 인스턴스와 똑같은 배열을 참조하게 됩니다.
@Override
public Stack clone() {
try {
Stack result = (Stack) super.clone();
result.elements = elements.clone();
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
Stack 클래스의 clone 메서드는 elements 배열의 clone 메서드를 재귀적으로 호출해 완벽히 구현할 수 있습니다. 배열의 clone 메서드는 항상 원본 배열과 똑같은 배열을 반환하므로 배열을 복제할 때는 예외적으로 되도록 clone 메서드를 사용하는 것을 권장합니다.
한편, elements 필드에 만일 final 한정자가 붙는다면 새로운 값을 할당할 수 없기 때문에 앞선 방식이 작동하지 않습니다. 즉, 우리는 여기서 Cloneable 아키텍처가 '가변 객체를 참조하는 필드는 final로 선언하라'라는 암묵적인 규칙과 충돌한다는 점을 알 수 있습니다.
clone을 재귀적으로 호출하는 것만으로 충분하지 않은 경우
이번에는 해시테이블을 복제하는 clone 메서드를 구현해봅시다. 해시테이블은 버킷들의 배열로 이루어져 있고, 각 버킷은 키-값 쌍을 담는 연결 리스트의 첫 번째 엔트리를 참조합니다. 해시테이블에 대한 구현 코드는 아래와 같습니다.
public class HashTable implements Cloneable {
private Entry[] buckets = ...;
private static class Entry {
final Object key;
Object value;
Entry next;
Entry(Object key, Object value, Entry next) {
this.key = key;
this.value = value;
this.next = next;
}
}
@Override
public HashTable clone() {
try {
HashTable result = (HashTable) super.clone();
result.buckets = buckets.clone();
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
...
}
해시테이블의 buckets 배열을 아까 전 예시처럼 배열의 clone 메서드를 재귀적으로 호출해 복제했습니다. 이 때 HashTable의 복제본은 자신만의 새로운 buckets 배열을 갖지만, buckets 배열은 원본과 같은 Entry를 참조하게 되는 문제가 발생합니다. 이 충돌을 해결하기 위해서는 각 버킷을 구성하는 Entry를 복사해야 합니다.
public class HashTable implements Cloneable {
private Entry[] buckets = ...;
private static class Entry {
final Object key;
Object value;
Entry next;
Entry(Object key, Object value, Entry next) {
this.key = key;
this.value = value;
this.next = next;
}
// 이 엔트리가 가리키는 연결 리스트를 재귀적으로 복사
Entry deepCopy() {
return new Entry(key, value,
next == null ? null : next.deepCopy());
}
@Override
public HashTable clone() {
try {
HashTable result = (HashTable) super.clone();
result.buckets = new Entry[buckets.length];
for (int i = 0; i < buckets.length; i++)
if (buckets[i] != null)
result.buckets[i] = buckets[i].deepCopy();
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
...
}
HashTable.Entry 클래스가 깊은 복사를 지원하도록 구현해봤습니다.
참고)
- 깊은 복사: 실제 값을 새로운 메모리 공간에 복사합니다.
- 얕은 복사: 주소 값을 복사합니다. 즉, 원본과 복제본이 참조하는 값은 같습니다.
이제 clone 메서드는 새 buckets 배열을 할당한 다음, 각 버킷에 대해 깊은 복사를 수행합니다. 깊은 복사를 의미하는 Entry의 deepCopy 메소드는 자신이 가리키는 연결 리스트 전체를 복사하기 위해 스스로를 재귀적으로 호출합니다. 재귀 호출은 간단하고 연결리스트가 길지 않을 때에는 잘 작동하지만, 리스트 원소수 만큼 스택 크기를 소비하며 리스트가 길어지면 스택 오버플로가 발생할 위험이 있습니다. 그러므로 유연하게 사용하고 싶다면 재귀 호출 대신 반복자를 써서 순회하는 방향으로 수정하는 것이 좋습니다.
Entry deepCopy() {
Entry result = new Entry(key, value, next);
for (Entry p = result; p.next != null; p = p.next)
p.next = new Entry(p.next.key, p.next.value, p.next.next);
return result;
}
이렇게 해시 테이블 예제를 통해 clone 메서드로 복잡한 가변 객체를 복제하는 방법까지 알아봤습니다.
Clone 메서드 사용 시 주의 사항
1. Clone 메서드에서 재정의될 수 있는 메서드는 호출하지 않는다.
생성자와 마찬가지로 clone 메서드에서도 재정의될 수 있는 메서드는 호출하지 않아야 합니다. clone이 하위 클래스에서 재정의한 메서드를 호출하면, 하위 클래스는 복제 과정에서 자신의 상태를 교정할 기회를 잃어버리게 됩니다. 즉, clone에서 호출되는 메서드들은 재정의가 불가능한 final이거나 private 메서드여야 합니다.
2. clone 메서드에서 throws 절을 없앤다.
Object의 clone메서드는 CloneNotSupportedException을 던진다고 선언되어있으나, 재정의한 메서드는 CloneNotSupportedException 예외를 던져서는 안됩니다.
3. 상속할 클래스에서는 Cloneable을 구현하면 안된다.
Cloneable을 구현하지 않거나 또는 다음처럼 clone을 동작하지 않게 구현해놓고 하위 클래스에서 재정의하지 못하게 할 수도 있습니다.
@Override
protected final Object clone() thrwos CloneNotSupportedException {
throw new CloneNotSupportedException();
}
4. clone 메서드는 동기화를 신경쓰지 않는다
Cloneable을 구현한 스레드 안전 클래스를 작성할 때는 clone 메서드 역시 적절히 동기화해줘야 합니다.
clone에 대한 요약
1. Cloneable을 구현하는 모든 클래스는 반드시 clone을 재정의해야 합니다.
2. clone의 접근자는 public으로, 반환 타입은 클래스 자신으로 변경합니다.
3. clone 메서드는 가장 먼저 super.clone을 호출한 후 필요한 필드를 전부 적절히 수정합니다.
여기서 필드를 적절히 수정한다는 것은 복사할 객체의 깊은 내부에 숨어있는 모든 가변 객체를 복사하고, 복제본이 가진 객체 참조 모두가 복사된 객체를 가리키게 함을 뜻합니다.
clone보다는 복사 생성자/팩터리를
사실 clone이 잘 작동하지 않는 상황에서는 복사 생성자와 복사 팩터리라는 객체 복사 방식을 사용하는 것이 좋습니다. 둘을 '변환 생성자/팩터리'라고 부르기도 합니다.
복사 생성자란 단순히 자신과 같은 클래스의 인스턴스를 인수로 받는 생성자를 말합니다.
public Yum(Yum yum) { ... };
복사 팩터리는 복사 생성자를 모방한 정적 팩터리입니다.
public static Yum newInstance(Yum yum) { ... };
이 둘은 clone 방식보다 나은 면이 많습니다. 모순적이고 위험천만한 생성 메커니즘을 사용하지 않으며, 엉성하게 문서화된 규약에 기대지 않고, 정상적인 final 필드 용법과도 충돌하지 않으며 불필요한 검사 예외를 던지지 않고 형변환도 필요하지 않습니다. 특히 파라미터로 인터페이스 타입을 받기 때문에 원본의 객체를 다른 타입의 객체로 복제할 수 있다는 장점이 있습니다.
Set<Integer> set = new HashSet<>();
set.add(1);
set.add(2);
// 변환 생성자의 예시(HashSet -> ArrayList)
List<Integer> list = new ArrayList<>(set);
결론적으로 복제 기능은 생성자와 팩터리를 이용하는게 가장 좋습니다.