[Effective Java] clone 재정의는 주의해서 진행하라

들어가며

Cloneable은 복제해도 되는 클래스임을 명시하는 용도인 믹스인 인터페이스지만 아쉽게도 의도한 목적을 제대로 이루지 못했다. 가장 큰 문제는 clone 메소드가 선언된 곳이 Cloneable이 아닌 Object이고 그마저도 protected라는데 있다.

Cloneable

메소드 하나 없는 Clonealbe 인터페이스는 놀랍게도 Object의 protected 메소드인 clone의 동작 방식을 결정한다. Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환하며 그렇지 않은 클래스의 인스턴스에서 호출하면 CloneNotSupportedException을 던진다. 게다가 Cloneable을 구현한 thread safe 클래스를 작성할 때는 clone 메소드 역시 적절한 동기화가 필요하다. Object의 clone 메소드는 동기화를 신경쓰지 않았기 때문이다.

clone 메소드 일반 규약

  1. x.clone() != x
  2. x.clone().getClass() == x.getClass()
  3. x.clone().equals(x)

강제성이 없다는 점만 빼면 생성자 연쇄와 살짝 비슷한 메커니즘이다. 즉 clone 메소드가 super.clone이 아닌 생성자를 호출해 얻은 인스턴스를 반환해도 컴파일러는 오류를 내지 않을 것이다. 하지만 이 클래스의 하위 클래스에서 super.clone을 호출한다면 잘못된 클래스의 객체가 만들어져 결국 하위 클래스의 clone 메소드가 제대로 동작하지 않게 된다.

clone을 재정의한 클래스가 final이라면 걱정해야할 하위 클래스가 없으니 이 관례는 무시해도 안전하지만 final 클래스의 clone 메소드가 super.clone을 호출하지 않는다면 Cloneable을 구현할 이유도 없다.

가변상태를 참조하지 않는 클래스용 clone 메소드

가변상태를 참조하지 않은 즉 모든 필드가 기본 타입이거나 불변 객체를 참조한다면 다음과 같이 clone 메소드를 구성할 수 있다.

1
2
3
4
5
6
7
8
@Override
public PhoneNumber clone() {
try {
return (PhoneNumber) super.clone();
} catch (ConleNotSupportedException e) {
throw new AssertionError(); // 일어날 수 없다.
}
}

가변상태를 참조하는 클래스용 clone 메소드

가변 상태를 참조하는 클래스는 깊은 복사를 진행해주어야 한다. 그냥 단순히 super.clone을 호출한다면 원본의 참조 필드와 동일한 필드를 참조하게 될 것이다. 이를 해결하는 방법은 참조 필드의 clone을 재귀적으로 호출해 주는 것이다.

1
2
3
4
5
6
7
8
9
10
@Override
public Stack clone() {
try {
Stack result = (Stack) super.clone();
result.elements = elements.clone();
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}

다만 elements 필드가 final이었다면 위의 방식은 동작하지 않는다. final 필드에는 새로운 값을 할당할 수 없기 때문이다. 이는 근본적인 문제로, 직렬화와 마찬가지로 Cloneable 아키텍처는 가변 객체를 참조하는 필드는 final로 선언하라는 일반 용법과 충돌한다. (단 원본과 복제된 객체가 그 가변 객체를 공유해도 안전하다면 괜찮다.) 그래서 복제할 수 있는 클래스를 만들기 위해 일부 필드에서 final 한정자를 제거해야 할 수도 있다.

단순해 재귀적으로 호출하는것이 충분하지 않을 때도 있다. 해시 테이블용 clone 메소드를 보자.

1
2
3
4
5
6
7
8
9
10
@Override
public HashTable clone() {
try {
HashTable result = (HashTable) super.clone();
result.buckets = buckets.clone();
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}

위 코드는 복제본은 자신만의 버킷 배열을 가지지만 이 배열은 원본과 같은 연결리스트를 참조하는 문제가 있다. 그럼 다음은 어떨까?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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();
}
}

deepCopy를 지원하도록 보강하였다. 언듯 보기에는 문제가 없어보인다. 하지만 재귀적으로 작성한 코드는 리스트의 원소 수만큼 스택 프레임을 소비하여 경우에 따라 스택 오버플로를 일으킬 수 있다. 이는 반복자를 활용하여 해결할 수 있다.

1
2
3
4
5
6
7
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;
}

객체 복사의 다른 방법

객체 복사의 다른 방법중 하나는 복사 생성자와 복사 팩터리다.

1
2
public Yum(Yum yum) { ... };
public static Yun newInstance(Yun yun) { ... };

위 방법은 언어 모순적이고 위험 천만한 객체 생성 메커니즘(생성자를 쓰지 않는 방식)을 사용하지 않으며 엉성하게 문서화된 규약에 기대지 않고 정상적인 final 필드 용법과도 충돌하지 않으며 불필요한 검사 예외를 던지지도 않고 형변환도 필요하지 않다. 그리고 해당 클래스가 구현한 인터페이스 타입의 인스턴스를 인수로 받을 수 있다. 이를 통하면 클라이언트는 원본의 구현 타입에 얽매이지 않고 복제본의 타입을 직접 선택할 수 있다.

Author: Song Hayoung
Link: https://songhayoung.github.io/2020/08/06/Languages/Effective%20JAVA/item13/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.