ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Item 13. clone 재정의는 주의해서 진행하라
    백수의 개발/이펙티브 자바 2019. 8. 27. 20:32

     

    Cloneable 인터페이스

    Cloneable은 복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스(mixin interface)이다.

    그러나 clone 메서드가 Cloneable이 아닌 Object에 protected로 명시되어 있다. 그래서 Cloneable을 구현한다고 해서 clone을 호출할 수 없다. 그럼에도 불구하고 Cloneable 방식은 널리 사용되고 있어 올바르게 사용하는 방법을 알아보자.

     

    Cloneable의 역할

    메서드 하나 없는 Cloneable 인터페이스는 Object의 protected 메서드인 clone의 동작 방식을 결정한다.

    Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면 그 객체의 필드들을 모두 복사한 객체를 반환하며, 그렇지 않은 클래스의 인스턴스에서 호출하면 CloneNotSupportedException을 던진다.

     

    실무에서 Cloneable을 구현한 클래스는 clone 메서드를 public으로 제공하며, 사용자는 복제가 제대로 이뤄지리라 믿고 사용하게 된다.

    이를 위해 해당 클래스와 모든 상위 클래스는 복잡하고, 강제할 수 없고, 허술하게 기술된 프로토콜을 지켜야만 한다. 그 결과 깨지기 쉽고, 위험하며, 모순적인 매커니즘이 탄생한다.

     

    Clone 메서드 일반 규약

    x.clone() != x;
    x.clone().getClass() == x.getClass();
    x.clone().equals(x);

    이를 반드시 만족해야 하는 것은 아니다.

    그러나 관례상, clone 메서드가 반환하는 객체는 super.clone을 호출해 얻어야 한다. 해당 관례를 따른다면 위의 식들을 모두 참이다.

     

    Clone 구현하기

    가변 상태를 참조하지 않는 클래스용 clone 메서드는 아래와 같이 단순하게 작성할 수 있다.

    public class PhoneNumber{
    	...
    	@Override
    	public PhoneNumber clone(){
    		try{
    			return (PhoneNumber) super.clone();
            } catch(CloneNotSupportedException e){
    			throw new AssertionError();
    		}
    	}
    	...
    }

    그러나 가변 상태를 참조하는 클래스인 경우 위와 같이 작성하면 문제가 생긴다.

    아래 코드를 통해 확인해보자.

    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;
    	}
        
    	// 가변 상태를 참조하지 않는 클래스용 clone 메서드
    	@Override
    	public Stack clone() {
    		try {
    			return (Stack) super.clone();
    		} catch (CloneNotSupportedException e) {
    			throw new AssertionError();
    		}
    	}
        
    	// 가변 상태를 참조하는 클래스용 clone 메서드
    	@Override
    	public Stack clone() {
    		try {
    			Stack result = (Stack) super.clone();
    			result.elements = elements.clone();
    			return result;
    		} catch (CloneNotSupportedException e) {
    			throw new AssertionError();
    		}
    	}
    
    	private void ensureCapacity() {
    		if (elements.length == size) {
    			elements = Arrays.copyOf(elements, 2 * size + 1);
    		}
    	}
    }

    위 Stack 클래스에서 Object 배열인 elements는 가변 상태이다.

    이에 대해 가변 상태를 참조하지 않는 클래스 처럼 clone을 단순히 정의하게 되면, clone된 객체에서의 elements에는 null값만 있게 된다.

    따라서 가변 상태인 elements에 대해 clone을 추가적으로 해주면 우리가 원하는 복제값들을 얻을 수 있다.

     

    이보다 조금 더 복잡한 가변 상태를 참조하는 클래스의 clone에 대해 보자.

    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;
    		}
    	}
    	...
    }

    현재 HashTable에서 Entry 배열은 또 다른 Entry를 참조하고 있는 형태이다. 이러한 경우 Stack과 같이 buckets들을 단순히 clone을 해보면 문제가 있다.

    @Override
    public HashTable clone(){
    	try{
    		HashTable result = (HashTable) super.clone();
    		result.buckets = buckets.clone();
    		return result;
    	} catch (CloneNotSupportedException e){
    		throw new AssertionError();
    	}
    }

    이는 연결 리스트를 참조하게 되어 원본과 복제본 모두 예기치 않게 동작할 가능성이 생긴다. 이를 해결하기 위해 버킷을 구성하는 연결 리스트를 복사해야 한다.

     

    연결 리스트는 재귀를 통한 방식과 반복자를 통한 방식이 있다. 아래 코드를 통해 확인해보자.

    class HashTable implements Cloneable{
    	private Entry[] buckets = ...;
    
    	private static class Entry{
    		...
    		Entry deepCopy(){
    			// 재귀적 deepCopy
    			return new Entry(key, value, next == null ? null : next.deepCopy());
    			
    			// 반복자 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;
    		}
    	}
    
    	@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를 해주고 이를 바탕으로 clone을 정의하여 연결 리스트를 참조하는 클래스도 올바르게 복제할 수 있다.

     

    Cloneable을 구현한 스레드 안전 클래스를 작성할 때는 clone 메서드 역시 적절히 동기화해줘야 한다. 이를 꼭 기억하자.

     

    복사 생성자와 복사 팩터리

    cloneable을 구현하지 않고 복사 생성자와 복사 팩터리를 통해 이를 해결할 수 있다.

    // 복사 생성자
    public Yum(Yum yum) { ... }
    
    //복사 팩터리
    public static Yum newInstance(Yum yum) { ... };

    위와 같이 생성자 또는 정적 팩터리 메서드에 해당 객체를 전달하여 새로운 인스턴스를 만들도록 하여 인스턴스를 복사할 수 있다.

     

    이는 Cloneable과 같이 언어 모순적이고 위험한 객체 생성 메커니즘을 사용하지 않으며, 엉성하게 문서화된 규약에 기대지 않고, 정상적인 final 필드 용법과도 충돌하지 않으며, 불필요한 검사 예외를 던지지 않고, 형변환도 필요하지 않다.

     

    게다가 해당 클래스가 구현한 인터페이스타입의 인스턴스를 인수로 받을 수 있다는 장점도 가지고 있다.,

     

    마무리

    새로운 인터페이스를 만들 때는 절대 Cloneable을 확장해서는 안되며, 새로운 클래스도 이를 구현해서는 안 된다.

    final 클래스라면 Cloneable을 구현해도 위험이 크지 않지만, 성능 최적화 관점에서 검토한 후 별다른 문제가 없을 때만 드물게 허용해야 한다. 기본 원칙은 '복제 기능은 생성자와 팩터리를 이용하는 게 최고'라는 것이다. 단, 배열만은 clone 메서드 방식이 가장 깔끔하고, 이 규칙의 합당한 예외로 볼 수 있다.

    댓글

Designed by Tistory.