DEV ℧ Developer Diary

[EffectiveJava] item13 - clone 재정의는 주의해서 진행하라.

clone 재정의는 주의해서 진행하라.

Cloneable은 복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스 (mixin interface)지만, clone 메서드가 선언된 곳이 Cloneable이 아닌 Object이고, protected라는 문제점이 있다.

clone() 메서드는 원본 객체의 필드값과 동일한 값을 가지는 새로운 객체를 생성한다. Cloneable를 implements하여 사용 한다.

Cloneable의 동작방식

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

  1. Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면 그 객체의 필드들을 하나하나 복사한 객체들을 반환한다.
  2. Cloneable을 구현하지 않을 경우 CloneNotSupportedException을 던진다.

Object 명세에서 가져온 clone 설명

이 객체의 복사본을 생성해 반환한다. ‘복사’의 정확한 뜻은 그 객체를 구현한 클래스에 따라 다를 수 있다. 일반적인 의도는 다음과 같다. 어떤 객체 x에 대해 다음 식은 참이다.

x.clone() != x

또한 다음 식도 참이다.

x.clone().getClass() == x.getClass()

하지만 이상의 요구를 반드시 만족해야 하는 것은 아니다. 한편 다음 식도 일반적으로 참이지만, 역시 필수는 아니다.

x.clone().equals(x)

관례상, 이 메서드가 반환하는 객체는 super.clone을 호출해 얻어야 한다. 이클래스와 (Object를 제외한) 모든 상위 클래스가 이 관례를 따른다면 다음 식은 참이다.

x.clone().getClass() == x.getClass()

관례상, 반환된 객체와 원본 객체는 독립적이어야 한다. 이를 만족하여면 super.clone으로 얻은 객체의 필드 중 하나 이상을 반환 전에 수정해야 할 수도 있다.

clone의 구현

clone 메서드 정의

PhoneNumber 클래스에 대한 clone 메소드를 정의해보자.

public class PhoneNumber implements Cloneable{
    ...

    @Override
    public PhoneNumber clone() {
        try{
            return (PhoneNumber) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError(); /* 일어날 수 없다. */
        }
    }
}

이렇게 clone을 사용하기 위해서는 Cloneable 인터페이스를 상속받아야 한다.

또한 Object의 clone 메서드는 Object를 반환하지만, PhoneNumber의 clone은 PhoneNumber를 반환한다. 해당 방식은 자바에서 권장하는 방식이기도 하고, 재정의한 메서드의 반환 타입은 상위 클래스의 메서드가 반환하는 타입의 하위 타입일 수 있다.

super.clone() 호출은 try-catch 블록으로 감싼 이유는 Object의 clone 메서드가 검사 예외 (checked exception)인 CloneNotSupportedException을 던지도록 선언 되었기 때문이다. 하지만 Cloneable를 implements하기 때문에 super.clone는 성공할 것이다. 그러므로 CloneNotSupportedException는 비검사 예외 (unchecked exception)으로 돌려도 된다.

단순한 가변 객체 clone 메서드 정의

간단한 clone의 구현이 가변 객체를 참조하는 순간 큰 재앙으로 돌변 할 수 있다. 아래의 예시를 통해 확인 해보자. 아래는 item07 에서 구현한 Stack 클래스이다.

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

    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 메소드를 정의해보자.

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

여기서 clone 메서드가 단순히 super.clone의 결과를 그대로 반환 한다면, 반환된 Stack 값의 size는 옳바른 값을 가지지만 Stack 클래스 내부의 elements 필드는 원본 Stack 인스턴스와 같은 주소의 배열을 참조하게 된다. 아래와 같이 말이다.

public class StackMain {
    /* Stack클래스와 clone한 객체의 주소값 출력 */
    public static void main(String[] args) {
        Stack stack = new Stack();

        stack.push(1);
        stack.push(2);

        System.out.println("stack : " + stack);
        System.out.println("stack : " + stack.elements);
        System.out.println("stack : " + stack.getSize());

        Stack clone = stack.clone();
        System.out.println("clone : " + clone);
        System.out.println("clone : " + clone.elements);
        System.out.println("clone : " + clone.getSize());
    }
}
stack size : 2
stack elements : [Ljava.lang.Object;@6b2fad11
stack : com.effectiveJava.item13.Stack@79698539
clone size : 2
clone elements : [Ljava.lang.Object;@6b2fad11
clone : com.effectiveJava.item13.Stack@73f792cf

이러한 상태는 원본이나 복제본 중 하나를 수정한다면 elements를 참조하고 있는 다른 하나의 객체도 수정되어 불변식을 해친다는 이야기다.

clone 메서드는 사실상 생성자와 같은 효과를 내기 때문에, clone은 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야 한다.

그래서 Stack의 clone 메서드는 제대로 동작하려면 스택 내부 정보를 복사해 주어야 한다. 내부정보를 복사 하기 위해 clone 메서드를 내부에 elements 배열의 clone을 재귀적으로 호출해 주는 것으로 변경해 보자.

@Override
protected Stack clone() {
    try {
        Stack result = (Stack) super.clone();
        /* 스택 내부 정보를 복사 */
        result.elements = elements.clone();
        return result;
    } catch (CloneNotSupportedException e) {
        throw new AssertionError();
    }
}

다시 한번 주소값을 출력 해보자.

stack size : 2
stack elements : [Ljava.lang.Object;@6b2fad11
stack : com.effectiveJava.item13.Stack@79698539
clone size : 2
clone elements : [Ljava.lang.Object;@73f792cf
clone : com.effectiveJava.item13.Stack@2ed94a8b

elements의 주소값이 다른 주소를 참조 하는 것을 볼 수 있다.

한편 elements의 필드가 final이라면 앞서의 방식은 작동하지 않는다. final필드에는 새로운 값을 할당할 수 없기 때문이다. Cloneable 아키텍처는 ‘가변 객체를 참조하는 필드는 final로 선언하라’는 일반 용법과 충돌한다. (단, 원본과 clone 객체가 그 가변 객체를 공유해도 안전하다면 괜찮다.) 그래서 clone해야할 객체는 일부 필드에서 final을 제거해야 할 수 도 있다.

복잡한 가변 객체 clone 메서드 정의

clone을 재귀적으로 호출하는 것만으로 충분하지 않을 때가 있다. 이번에는 HashTable용 clone 메서드르 생각해보자. 해시테이블 내부는 버킷들의 배열이고, 각 버킷은 key-value 쌍을 담은 연결 리스트의 첫 번째 엔트리를 참조한다.

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;
        }
    }
    ... /* 나머지 코드는 생략 */
}

앞서 Stack에 구현한것과 같이 단순 배열의 clone을 재귀적으로 호출 해보자

@Override
protected HashTable clone() {
    try {
        HashTable result = (HashTable) super.clone();
        /* 스택 내부 정보를 복사 */
        result.buckets = buckets.clone();
        return result;
    } catch (CloneNotSupportedException e) {
        throw new AssertionError();
    }
}

해당 복제본은 자신만의 버킷 배열을 갖지만, 이 배열은 원본과 같은 연결 리스트를 참조하여 원본과 복제본 모두 원치 않을 동작이 발생할 가능성이 생긴다.

이를 해결하기 위해서는 각 버킷을 구성하는 연결 리스트를 복사해야한다. 아래와 같이 구현을 해주면 된다.

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
    protected 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을 보강 하였고, HashTable의 clone 메서드는 먼저 적절한 크기의 새로운 buckets의 배열을 할당 한다음 원래의 버킷 배열을 순회하며, 비어있지 않은 각 버킷에 대해 연결된 항목들을 복사해온다. 이때 Entry의 deepCopy 메서드는 자신이 가리키는 연결리스트 전체를 복사하기 위해 자신을 재귀적으로 호출한다.

하지만 이 방법은 연결리스트가 너무 길어지면 StackOverflow를 일으킬 위험이 있기 때문에 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

상속해서 쓰기 위한 클래스 설계 방식에서는 Cloneable을 구현해서는 안된다. Cloneable를 막기 위한 방법에는 여러가지가 있다.

  1. Object의 방식처럼 접근제어자를 protected로 두고 CloneNotSupportedException를 던질 수 있다고 선언한다.
  2. clone를 동작하지 않게 구현한다.

2번의 방식은 아래와 같다.

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

멀티 쓰레드 환경에서의 clone

clone에 대해 중요한 키워드가 하나 남았다.

Thread-safe한 clone 구현을 위해서라면 clone 메서드 역시 적절히 동기화해줘야 한다. 기본 적인 Object의 clone 메서드는 동기화를 신경 쓰지 않는다. 그러므로 super.clone 호출 외에 다른 할 일이 없더라도 clone을 재정의하고 동기화 해줘야 한다.

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

복사 생성자와 복사 팩터리

Cloneable을 이미 구현한 클래스를 확장한다면, 다음과 같은 팩터리 방식을 사용할 수 있다.

public Yum(Yum yum) {...};

복사 팩터리는 복사 생성자를 모방한 정적 팩터리 item01 이다.

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

복사 생성자와 복사 팩터리는 clone 보다 나은 면이 있다. 해당 클래스가 구현한 인터페이스를 인수로 받을 수 있으며, Collection이나 Map타입을 받는 생성자를 제공하기 때문에 다른 Collection객체로 복사가 용이하다.

예를 들어 HashSet s에 대한 객체를 TreeSet으로 복사해 변환하고자 할때, clone은 에러를 출력하지만, new TreeSet<>(s)를 이용하변 변환 및 복사가 쉽게 가능하다.

import java.util.HashSet;
import java.util.TreeSet;

public class CloneMain {
    public static void main(String[] args) {
        HashSet<String> hashSet = new HashSet<>();
        hashSet.add("apple");
        hashSet.add("banana");

        /* 에러 발생 */
        TreeSet<String> clone = (TreeSet<String>) hashSet.clone();
        System.out.println(clone.first());
        System.out.println(clone.last());

        /* apple, banana 출력 */
        TreeSet<String> copy = new TreeSet<>(hashSet);
        System.out.println(copy.first());
        System.out.println(copy.last());
    }
}

정리

  1. Cloneable을 구현하는 모든 클래스는 clone을 재정의 해준다.
  2. 접근 제한자는 public, 반환 타입은 클래스 자신으로 변경한다.
  3. 메서드는 가장 먼저 super.clone을 호출한 후 필요한 필드를 전부 적절히 수행한다.
  4. 그 객체의 내부 ‘깊은 구조’에 숨어 있는 모든 가변 객체를 복사하고, 복제본이 가진 객체 참조 모두가 복사된 개체를 가리키게 한다.