본문 바로가기

Reading/Effective Java

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

clone()을 정의하기 위해서는 Clonable 믹스인 인터페이스를 구현해야한다.

하지만 clone() 메소드가 선언된 곳은 믹스인 인터페이스인 Clonable이 아니라 Object이고, 그마저도 protected다.

Clonable을 구현하는 것 만으로는 clone 메소드를 호출할 수 없고, 리플렉션을 사용하면 가능하겠지만 100% 성공도 아니다.

 

Clonable 인터페이스는 Object의 protected 메소드인 clone 메소드의 동작 방식을 변경한다.

실제 Clonable 인터페이스는 비어있고, 선택적으로 clone()을 오버라이딩할 수 있게 준비되어있다.(아래 코드에 구현하라고 나온다)

 

 

implements Clonable을 하고 clone()을 오버라이딩해서 사용하면 정상적으로 객체가 복사되고,

만약 implements Clonable 없이 clone()을 오버라이딩해서 사용한다면 CloneNotSupportedException이 발생한다.

인터페이스를 상당히 이례적으로 사용한 예이고, 생성자를 호출하지 않고도 객체를 생성할 수 있어 허술하다.

 

* 믹스인 인터페이스(mixin interface): 복제해도 되는 클래스임을 명시하는 용도로 사용하고, 클래스가 구혀날 수 있는 타입으로, 믹스인을 구현한 클래스에 원래의 '주된 타입' 외에도 특정 선택적 행위를 제공한다고 선언하는 효과를 준다

 

 

clone() 메소드의 일반 규약


 

  1. x.clone() ! = x 은 참이다.
  2. x.clone().getClass() == x.getClass() 도 참이나, 반드시 만족해야하는 것은 아니다.
  3. x.clone().equals(x) 도 참이나 역시 필수는 아니다.
  4. 관례상 clone()메소드가 반환하는 객체는 super.clone()을 호출해 얻어야한다. 이 클래스와(Object를 제외한) 모든 상위 클래스가 이 관례를 따른다면 x.clone().getClass() == x.getClass()는 참이다.
  5. 관례상 반환된 객체와 원본 객체는 독립적이어야 한다. 이를 만족하려면 super.clone()으로 얻은 객체의 필드 중 하나 이상을 반환전에 수정해야할 수도 있다.

강제성이 없다는 점만 제외하면 생성자 연쇄(constructor chaining)와 비슷한 매커니즘이다.

하위 클래스가 상위 클래스의 defalut생성자를 호출하듯, clone() 메소드가 super.clone() 없이 생성자를 불러도 컴파일 시점에서 문제되지 않지만, 하위 클래스에서 super.clone()을 호출하면 잘못된 객체가 만들어져 하위 clone() 메소드가 제대로 동작하지 않게된다.

 

clone을 재정의한 클래스가 final이면 걱정할 하위클래스가 없으니 이 관례는 무시해도 안전하지만, final 클래스의 clone 메소드가 super.clone을 호출하지 않는다면 object의 clone 구현의 동작 방식에 기댈 필요가 없으니 Clonalbe을 구현할 이유도 없다.

 

 

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


 

@Getter
@Setter
public class TestClone implements Cloneable{
    private int number;
    public TestClone(int number){
        this.number = number;
    }
    @Override
    public TestClone clone() {
        try {
            return (TestClone) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
}

 

재정의한 메소드의 반환 타입은 상위 클래스의 메소드가 반환하는 타입의 하위 타입일 수 있다.

이게 가능한 이유는 자바의 공변반환 타이핑(convariant return typing)을 지원하기 때문이다.

 

따라서 클라이언트는 형변환을 하지 않아도 되고, super.clone()을 통해 Object의 클론을 TestClone으로 캐스팅해서 반환하면 된다.

 

try-catch문으로 감싼 이유는, 검사 예외(checked exception)인 CloneNotSupportedException을 던지도록 선언됐기 때문이다.

어디까지나 super.clone() 통해 성공할 것임을 알기에 비검사 예외(unchecked Exception)였어야 했다. 

 

* 검사예외(Checked Exception): 컴파일 단계에서 확인할 수 있는 예외. 발생 시 롤백안한다. ex) IOException, SQLException
* 비검사예외(Unchecked Exception): 런타임에서 실행시 발생하는 예외.발생 시 롤백한다. ex) NPE, OutOfBoundsException

 

 

 

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



 이전 스택 클래스를 복제 가능하게 clone을 오버라이딩 해보자. 우선 기존은 아래와 같다.

 

@Getter
public class Stack implements Cloneable {
    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);
    }
}

 

위와같이 존재하는 스택 클래스를 단순히 super.clone의 결과를 반환하면 어떻게 될까?

elements 필드는 원본 Stack 인스턴스와 똑같은 배열을 참조할 것이다. 따라서 이상하게 동작하거나 NPE가 발생한다.

 

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

 

스택 클래스의 하나뿐인 생성자를 호출하면 이러한 상황은 발생하지 앟는다. clone 메소드는 사실상 생성자와 같은 효과를 낸다.

clone은 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야한다. 제대로 동작하게 하기 위해서

스택 내부 정보를 복사해야하는데, Object[] 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을 제거할 수 있다.

 

    @Test
    @DisplayName("clone 호출 시 배열도 따로 복제하면 원본과 같은 참조를 갖지 않는다.")
    public void stackElementNotShare() throws Exception {
        //given
        String[] value = {"hi", "hi2", "hi3"};
        Stack original = new Stack();
        for (String val : value)
            original.push(val);
        Stack copy = original.clone();
        //when
        copy.changeFirstElement("change!");
        //then
        assertNotEquals(copy.getElements()[0], original.getElements()[0]);
        assertNotSame(copy.getElements(), original.getElements());
    }

 

위 코드의 테스트는 통과한다. changeFirstElement는 첫번째 원소의 값을 매개변수로 넘어온 값을 반환하는데,

copy의 elements를 수정해도 원본의 elements는 변하지않음을 알 수 있다.

 

 

 

해시테이블용 clone() 메소드


간단한 해시테이블을 보자. 해시테이블 내부에 Entry 배열을 가진 변수가 있고, Entry는 간단한 연결리스트로 이뤄져있다.

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
    
    transient Node<K,V>[] table;
    
    static class Node<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Node<K,V> next;

        Node(K key, V value, Node<K,V> 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.tables = tables.clone();
            return result;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }

 

복제본은 자신만의 버킷 배열을 가지만 이 배열은 원본과 같은 연결리스트를 참조하여 예기치않게 동작할 수 있다.다음 코드가 안전하다.

 

    @Override public HashTable clone() {
        try {
            HashTable result = (HashTable) super.clone();
            result.tables = new Node[tables.length];
            for(int i = 0; i< tables.length;i++){
            	if(tables[i]!=null)
                	result.tables[i] = tables[i].deepCopy();
            }
            return result;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }

 

새로운 버킷 배열을 할당한 다음 원래의 버킷 배열을 순회하며 비지 않은 각 버킷에 대해 깊은 복사를 수행하고, deepCopy() 메소드를 통해서 연결리스트 전체를 복사하기 위해 재귀적으로 호출을 한다. 하지만 연결리스트 보게는 원소 수만큼 스택 프레임을 소비하여 리스트가 길면 StackOverflow가 발생할 수 있다. 이 문제는 반복자를 써서 순회하는 방향으로 수정해야한다.

 

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

 

실제 java.util의 HashMap은 고수준 메소드를 호출한다.

super.clone을 호출하여 얻은 객체의 모든 필드를 초기 상태로 설정하고, 원본 객체의 상태를 다시 생성하는 고수준 메소드 호출,

그리고 put 메소드를 호출하여 내용을 똑같이 하여 간단해보이지만 저수준 처리보다는 느리다.

그리고 필드 단위 객체 복사를 위호해서 Cloneable 아키텍처와는 어울리지 않기도 한다.

 

    @Override
    public Object clone() {
        HashMap<K,V> result;
        try {
            result = (HashMap<K,V>)super.clone();
        } catch (CloneNotSupportedException e) {
            // this shouldn't happen, since we are Cloneable
            throw new InternalError(e);
        }
        result.reinitialize();
        result.putMapEntries(this, false);
        return result;
    }

    final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
        int s = m.size();
        if (s > 0) {
            if (table == null) { // pre-size
                float ft = ((float)s / loadFactor) + 1.0F;
                int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                         (int)ft : MAXIMUM_CAPACITY);
                if (t > threshold)
                    threshold = tableSizeFor(t);
            }
            else if (s > threshold)
                resize();
            for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
                K key = e.getKey();
                V value = e.getValue();
                putVal(hash(key), key, value, false, evict);
            }
        }
    }

 

생성자에서 재정의될 수 있는 메서드를 호출하지 않듯 clone 메소드에서도 마찬가지다.

clone이 하위 클래스에서 재정의한 메소드를 호출하면 하위 클래스는 복제 과정에서 자신의 상태를 교정할 기회를 잃고,

원본과 복제본의 상태가 달라질 가능성이 크다. putMapEntries가 final인 이유고 이와 다른 객체에서는 private도 올 수도 있다.

하위클래스에서 상위클래스의 메소드를 호출하고, 하위클래스에서 재정의한 메소드를 호출 시,

상위클래스에서 호출했음에도 불구하고 하위클래스의 재정의된 메소드가 호출이 되었다.

 

 

 

clone() 메소드 주의점


1. Object의 clone() 메소드는 CloneNotSupportedException을 던진다고 선언했지만, 재정의한 메소드는 그렇지 않다.

public인 clone() 메소드는 throws 절을 없애야하고, 검사 예외를 던지지 않아야 그 메소드를 사용하기 편하다.

 

2. 상속용 클래스는 Cloneable을 구현해선 안된다.

  • 즉 public으로 하위클래스들이 외부 패키지에서 사용되는 것을 막는 것.
  • Object 방식을 모방하여 제대로 작동하는 clone 메소드를 구현해 외부패키지에서 가져올 수 없게 protected로 두고 CloneNotSupportedException을 던질 수 있다고 선언하면 Cloneable 구현 여부를 하위 클래스에서 선택하도록 해준다. 
  • clone을 동작하지 않게 final로 선언하고 하위클래스에서 재정의하지 못하게 한다.

만약 상위클래스가 AA이고, 하위 클래스가 BB라면, 

 

final로 선언시 아래 코드와 같다.

 

@Override
protected final Object clone() throws CloneNotSupportedException{
  throw new CloneNotSupportedException();
}

 

3. 스레드 안전 클래스를 작성할 땐 clone 메소드를 적절히 동기화해줘야 한다.

Object clone메소드는 동기화를 신경쓰지 않고 있고, super.clone 호출 외 다른 일이 없더라도 clone을 재정의하고 동기화 해줘야한다. 

 

 

 

요약


  1. Clonealbe을 구현하는 모든 클래스는 clone을 재정의 해야하고, 접근 제한자는 public, 반환 타입은 클래스 자기 자신으로 두자
  2. 메소드는 super.clone을 호출한 후 필요한 필드를 전부 적절히 수정한다. 즉, 객체의 내부 깊은 구조에 숨어 있는 모든 가변 객체를 복사하고, 복제본이 가진 객체 참조 모두가 복사된 객체를 가리키게 하는 것을 말한다.
  3. 스택오버플로우 방지를 위해 재귀보다는 복사 생성자나 복사 팩토리를 사용하자
  4. 기본 타입 필드와 불변 객체 참조만 갖는 클래스는 아무 필드도 수정할 필요가 없나. 일련번호나 고유 ID는 기본타입, 불변이라도 수정이 필요하다.

 

 

 

복사 생성자와 복사 팩토리


Cloneable을 이미 구현한 클래스를 확장한다면 어쩔 수 없이 clone을 잘 작동하도록 해야하지만, 그렇지 않은 경우에는

복사 생성자와 복사 팩토리라는 더 나은 객체 복사 방식을 제공할 수 있다.

 

복사 생성자는 단순히 자신과 같은 클래스의 인스턴스를 인수로 받는 생성자를 말한다. 복사 팩토리는 복사 생성자를 모방한 정적 팩토리다.

 

public Yum(Yum yum) { ... }
public static Yum newInstance(Yum yum) { ... }

 

Cloneable/clone 방식에 비교했을 때 아래의 장점을 가진다.

  1. 위험한 객체 생성 메커니즘(생성자를 쓰지 않는)을 사용하지 않는다.
  2. 엉성한 문서 규약에 기대지 않는다.
  3. 정상적인 final 필드 용법과도 충돌하지 않는다.
  4. 불필요한 검사 예외를 던지지않는다.
  5. 형변환이 필요하지 않다.
  6. 해당 클래스가 구현한 인터페이스 타입의 인스턴스를 인수로 받을 수 있다. [ ex) 모든 범용 컬렉션 구현체는 Collection이나 Map 타입을 받는 생성자를 제공. 인터페이스 기반의 더 정확한 이름은 변환 생성자와 변환 팩토리다 ]

따라서 이들을 이용하면 원본의 구현 타입에 얽매이지 않고 복제본의 타입을 직접 선택할 수 있다.

ex) HashSet<T> s = new HashSet<>(); Treeset<T> t = new TreeSet<>(s);

 

 

정리


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

final 클래스는 구현해도 위험이 크지 않지만, 성능 최적화 관점에서 검토 후 별 문제 없을때만 사용하자. 복제 생성자 팩토리가 최고고,

배열만은 clone 메소드 방식이 가장 깔끔하고 합당한 예외라고 할 수 있다.

 

 

 

* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.