아이템 13. clone 재정의는 주의해서 진행하라
Cloneable
은 복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스지만, 아쉽게도 의도한 목적을 제대로 이루지 못했다. Cloneable
의 문제점은 아래와 같다.
clone
메서드가 선언된 곳이Cloneable
이 아닌Object
이고,protected
이다. 그래서Cloneable
을 구현하는 것만으로는 외부 객체에서clone
메서드를 호출할 수 없다.- 리플렉션을 사용하면 접근 가능하지만, 100% 성공하는 것도 아니다.
하지만 이런 여러 문제점에도 불구하고 Cloneable
방식은 많이 쓰니까 잘 알아두는 것이 좋다. clone
메서드를 잘 동작하게 구현하는 방법, 언제 그렇게 해야 하는지 그리고 가능한 다른 선택지애 대해 알아본다.
Cloneable 인터페이스 역할
Cloneable
인터페이스는 Object
의 protected
메서드인 clone
의 동작 방식을 결정하는 역할을 한다. Cloneable
을 구현한 클래스의 인스턴스에서 clone
을 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환하고 그렇지 않은 클래스의 인스턴스에서 clone
을 호출하면 CloneNotSupportedException
을 던진다. 인터페이스를 상당히 이상하게 사용한 방식이니까 따라하지는 말자.
Cloneable 인터페이스 규약
실무에서 Cloneable
을 구현할 클래스는 clone
메서드를 public
으로 제공하며, 사용자는 당연히 복제가 제대로 이뤄질거라고 생각한다. 하지만 그렇게 하려면 해당 클래스와 모든 상위 클래스가 복잡하고, 강제할 수 없고, 허술하게 기술된 규약을 지켜야만 하는데, 그 결과로 깨지기 쉽고, 위험하고, 모순적인 메커니즘이 발생한다. clone
메서드의 일반 규약은 다음과 같은데 좀 허술한 규약이다.
이 객체의 복사본을 생성해 반환한다. '복사'의 정확한 뜻은 그 객체를 구현한 클래스에 따라 다를 수 있다. 일반적인 의도는 다음과 같다. 어떤 객체 x에 대한 다음 식은 참이다.
x.clone() != x
또한 다음 식도 참이다.
x.clone().getClass()==x.getClass()
하지만 이상의 요구를 반드시 만족해야 하는 것은 아니다.
한편 다음 식도 일반적으로 참이지만, 역시 필수는 아니다.
x.clone().equals(x)
관례상, 이 메서드가 반환하는 객체는 super.clone을 호출해 얻어야 한다. 이 클래스와 (Object를 제외한) 모든 상위 클래스가 이 관례를 따른다면 다음 식은 참이다. -> Object의 하위 클래스에서 clone메서드를 오버라이딩할 때 내부적으로 Object에 있는 clone메서드를 사용해서 구현하라는 얘기 같다.
x.clone().getClass()==x.getClass()
관례상, 반환된 객체와 원본 객체는 독립적이어야 한다. 이를 만족하려면 super.clone으로 얻은 객체의 필드 중 하나 이상을 반환 전에 수정해야 할 수도 있다.
Clone 메서드 구현
클래스의 하위 클래스에서 super.clone
을 호출한다면 잘못된 클래스의 객체가 만들어져, 결국 하위 클래스의 clone
메서드가 제대로 동작하지 않게 된다. -> super.clone
으로 얻은 객체 형변환 필요하다.
클래스의 필드가 기본 타입이거나 불변 객체를 참조하는 경우
제대로 동작하는 clone
메서드를 가진 상위 클래스를 상속해 Cloneable
을 구현해보자. 먼저 super.clone
을 호출한다. 클래스에 정의된 모든 필드는 원본 필드와 똑같은 값을 갖는다. 모든 필드가 기본 타입이거나 불변 객체를 참조한다면 제대로 복사된 것이다. 그런데 쓸데없는 복사를 지양한다는 관점에서 보면 불변 클래스는 굳이 clone
메서드를 제공하지 않는 게 좋다. 개인적인 알고리즘 문제 경험으로도 객체를 복사해서 사용할 때는 가변 객체의 원본은 보존하면서 변경을 가하고 싶은 경우였다. 불변 객체는 굳이 복사를 할 이유 없이 그대로 사용하면 될 것 같다. 이 점을 고려해서 불변 클래스인 PhoneNumber
클래스의 clone
메서드를 구현해봤다.
@Override public PhoneNumber clone(){
try{
return (PhoneNumber) super.clone();
}catch(CloneNotSupportedException e){
throw new AssertionError();
}
}
위의 메서드가 동작하게 하려면 PhoneNumber
클래스 선언에 Cloneable
을 구현한다고 추가해야 한다. super.clone()
에서 얻은 객체를 하위 클래스의 타입으로 형변환해서 클라이언트가 형변환하지 않아도 되게끔 해주자.
클래스의 필드가 가변 객체를 참조하는 경우
public class Stack {
private Object[] elements;
private int size=0;
private static final int DEFAULT_INITIAL_CAPACITY=16;
public Stack(){
elements=new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e){
ensureCapacity();
elements[size++]=e;
}
//pop() 코드에서 실제로 객체를 지우는 것이 아니라 size만 변경하고 있다. 필요없는 객체를 스택 안에 여전히 존재하는 것이다.
public Object pop(){
if(size==0)
throw new EmptyStackException();
return elements[--size];
}
private void ensureCapacity(){
if(elements.length==size)
elements= Arrays.copyOf(elements, 2*size+1);
}
}
이 클래스의 clone
메서드를 위의 경우와 같이 구현하면 참사가 난다. 가변 객체를 참조하는 elements
필드가 원본 Stack
인스턴스와 똑같은 배열을 참조할 것이다. 원본이나 복제본 중 하나를 수정하면 다른 하나도 수정되어 불변식을 해친다.
Stack
클래스의 하나뿐인 생성자를 호출한다면 이러한 상황은 절대 일어나지 않는다. clone
메서드는 사실상 생성자와 같은 효과를 낸다. 즉,clone은 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야 한다. 그래서 Stack
의 clone
메서드는 제대로 동작하려면 스택 내부 정보를 복사해야 하는데, 가장 쉬운 방법은 elements
배열의 clone
을 재귀적으로 호출해주는 것이다.
@Override
public Stack clone(){
try{
Stack result=(Stack) super.clone();
result.elements=elements.clone();
return result;
}catch(CloneNotSupportedException e){
throw new AssertionError();
}
}
elements.clone
의 결과를 Object[]
로 형변환할 필요는 없다. 배열의 clone
은 런타임 타입과 컴파일타임 타입 모두가 원본 배열과 똑같은 배열을 반환한다. 따라서 배열을 복제할 때는 배열의 clone
메서드를 사용하라고 권장한다. 사실, 배열은 clone
기능을 제대로 사용하는 유일한 예라고 할 수 있다.
한편, elements
필드가 final
이었다면 앞서의 방식은 작동하지 않는다. 이는 근본적인 문제로, 직렬화와 마찬가지로 Cloneable
아키텍처는 '가변 객체를 참조하는 필드는 final
로 선언하라'라는 일반 용법과 충돌한다. 그래서 복제할 수 있는 클래스를 만들기 위해서는 일부 필드에서 final
한정자를 제거해야 할 수도 있다.
clone
을 재귀적으로 호출하는 것만으로는 충분하지 않을 때도 있다. 해시테이블용 clone
메서드를 예로 든다. 해시테이블 내부는 버킷들의 배열이고 각 버킷은 키-값 쌍을 담는 연결 리스트의 첫 번째 엔트리를 참조한다. 그리고 성능을 위해 java.util.LinkedList
대신 직접 구현한 경량 연결 리스트를 사용한다.
public class HashTable implements Cloneable{
private Entry[] bukets=...;
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;
}
}
// 잘못된 clone 메서드 - 가변 상태를 공유한다.
@Override
public HashTable clone(){
try {
HashTable result=(HashTable) super.clone();
result.bukets=bukets.clone();
return result;
}catch (CloneNotSupportedException e){
throw new AssertionError();
}
}
}
복제본은 자신만의 버킷 배열을 갖지만, 이 배열은 원본과 같은 연결 리스트를참조하여 원본과 복제본 모두 예기치 않게 동작할 가능성이 생긴다. 즉, 복사된 배열의 Entry
요소가 원본 배열의 Entry
요소와 같은 key
, value
, next
를 공유해서 문제가 발생한다는 것이다. 다음이 같이 구현하면 문제가 해결된다.
public class HashTable implements Cloneable {
private Entry[] bukets =...;
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());
}
}
// 복잡한 가변 상태를 갖는 클래스용 clone 메서드
@Override
public HashTable clone(){
try {
HashTable result=(HashTable) super.clone();
result.bukets = new Entry[bukets.length];
for(int i=0; i< bukets.length; i++){
if(bukets[i]!=null)
result.bukets[i]=bukets[i].deepCopy();
}
return result;
}catch (CloneNotSupportedException e){
throw new AssertionError();
}
}
}
private
클래스인 HashTable.Entry
는 깊은 복사를 지원하도록 보강되었다. HashTable
의 clone
메서드는 먼저 적절한 크기의 새로운 버킷 배열을 할당한 다음 원래의 버킷 배열을 순회하며 비지 않은 각 버킷에 대해 깊은 복사를 수행한다. 이때 Entry
의 deepCopy
메서드는 자신이 가리키는 연결 리스트 전체를 복사하기 위해 자신을 재귀적으로 호출한다. 이 기법은 간단하며, 버킷이 너무 길지 않다면 잘 작동한다. 리스트가 길면 재귀 호출 때문에 스택 오버플로우가 발생할 수 있다. 이 문제를 피하려면 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;
}
복잡한 가변 객체를 복제하는 마지막 방법은 다음과 같다.
super.clone
을 호출하여 얻은 객체의 모든 필드를 초기 상태로 설정한다.- 원본 객체의 상태를 다시 생성하는 고수준 메서드들을 호출한다.
HashTable
로 위의 방식을 예를 들어 설명하겠다. bukets
필드를 새로운 버킷 배열로 초기화한 다음 원본 테이블에 담긴 모든 키-값 쌍 각각에 대해 복제본 테이블의 put(key, value)
메서드를 호출해 둘의 내용이 똑같게 해주면 된다. 이처럼 고수준 API
를 활용해 복제하면 보통은 간단하고 깔끔한 코드를 얻지만 저수준 코드보다 느리다. 또한 Cloneable
아키텍처의 기본이 되는 필드 단위 객체 복사를 우회하기 때문에 전체 Cloneable
아키텍처와 어울리지 않는 방식이기도 하다.
주의사항
생성자에서는 재정의될 수 있는 메서드를 호출하지 않아야 하는데 clone
메서드도 마찬가지다. 만약 clone
이 하위 클래서에서 재정의한 메서드를호출하면, 하위 클래스는 복제 과정에서 자신이 상태를 교정할 기회를 잃게 되어 원본과 복제본의 상태가 달라질 가능성이 크다. 위에서 얘기한 put(key, value)
메서드는 final
이거나 private
이어야 한다.(private
이라면 final
이 아닌 public
메소드가 사용하는 도우미 메서드일 것이다.)
Object
의 clone
메서드는 CloneNotSupportedException
을 던진다고 선언했지만 재정의한 메서드는 그렇지 않다. public
인 clone
메서드에서는 throws
저을 없애야 한다. 검사 예외를 던지지 않아야 그 메서드를 사용하기 편하기 때문이다.
상속해서 쓰기 위한 클래스 설계 방식 두 가지 중 어느 쪽에서든, 상속용 클래스는 Cloneable
을 구현해서는 안된다. Object
의 방식을 모방해도 된다. 제대로 작동하는 clone
메서드를 구현해 protected
로 두고 CloneNotSupportedException
도 던질 수 있다고 선언하는 것이다. 이 방식은 마치 Object
를 바로 상속할 때처럼 Cloneable
구현 여부를 하위 클래스에서 선택하도록 해준다. 다른 방법으로는, clone
을 동작하지 않게 구현해놓고 하위 클래스에서 재정의하지 못하게 할 수도 있다.
@Override
protected final Object clone() throws CloneNotSupportedException{
throw new CloneNotSupportedException();
}
Cloneable
을 구현한 스레드 세이프 클래스를 작성할 때는 clone
메서드 역시 적절히 동기화해줘야 한다. Object
의 clone
메서드는 동기화를 신경쓰지 않았다. 그러니 super.clone
호출 외에 다른 할 일이 없더라도 clone
을 재정의하고 동기화해줘야 한다.
요약하자면, Cloneable
을 구현하는 모든 클래스는 clone
을 재정의해줘야 한다. 이때 접근 제한자는 public
으로, 반환 타입은 클래스 자신으로 변경한다. 이 메서드는 가장 먼저 super.clone
을 호출한 후 필요한 필드를 전부 적절히 수정한다.
복사 생성자와 복사 팩터리
복사 생성자와 복사 팩터리라는 더 나은 객체 복사 방식이 있다. 복사 생성자란 단순히 자신과 같은 클래스의 인스턴스를 인수로 받는 생성자를 말한다.
public Yum(Yum yum){...};
public static Yum newInstance(Yum yum){...};
복사 생성자와 그 변형인 복사 팩터리는 Cloneable/clone
방식보다 나은 면이 많다. 언어 모순적이고 위험천만한 객체 생성 메커니즘(생성자를 쓰지 않는 방식)을 사용하지 않으며, 엉성한 규약에 기대지 않고, 정상적인 final
필드 용법과 충돌하지 않으며, 불피룡한 검사 예외를 던지지 않고, 형변환도 필요하지 않다.
여기서 끝이 아니다. 복사 생성자와 복사 팩터리는 해당 클래스가 구현한 '인터페이스'타입의 인스턴스를 인수로 받을 수 있다. 관례상 모든 범용 컬렉션 구현체는 Collection
이나 Map
타입을 받는 생성자를 제공한다. 인터페이스 기반 복사 생성자와 복사 팩터리의 더 정확한 이름은 '변환 생성자'와 '변환 팩터리'다. 이들을 이용하면 클라이언트는 원본의 구현 타입에 얽매이지 않고 복제본의 타입을 적절히 선택할 수 있다. 예를 들어 HashSet
객체를 TreeSet
타이븡로 복제할 수 있다.
Author And Source
이 문제에 관하여(아이템 13. clone 재정의는 주의해서 진행하라), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@mbsik6082/아이템-13.-clone-재정의는-주의해서-진행하라저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)