Programming/Java

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

VSFe 2021. 2. 17. 00:54
clone 재정의는 주의해서 진행하라!

clone은 원본 객체의 필드값과 동일한 값을 가지는 새로운 객체를 생성하는 메소드이다.

protected native Object clone() throws CloneNotSupportedException;

앗! native다. 또한 CloneNotSupportedException을 던지는데, 이는 clone을 사용할 수 없는 객체에서 clone을 사용하려고 할 때 발생하는 예외일 것이다. 그렇다면 clone을 쓸 수 있을지, 없을지 어떻게 판단할 수 있을까? 바로 Cloneable을 구현했는지 확인하면 된다.

그렇다면 Cloneable은 어떻게 생겼을까?

public interface Cloneable {}

텅텅 비었다? 대체 이게 무슨 역할을 할까? 구현되어 있기만 하면 객체의 필드를 하나하나 복사한 객체를 반환하고, 안되어 있으면 상술한 CloneNotSupportedException을 반환한다. 일반적으로 인터페이스를 구현한다는 것은 정의한 기능을 세부적으로 구현하는 것인데, 해당 인터페이스의 경우만 예외적으로 이러는 것이니 참고하길 바란다.

생각해보면, 객체를 생성자를 사용하지 않고 만드는 것이기 때문에, 어찌보면 이상한 존재이긴 하다.

규약에는 다음과 같이 적혀있다.

  • 어떤 객체 x에 대해 다음 식은 참이다.
    • x.clone() != x
    • x.clone().getClass() == x.getClass()
  • 다음 식은 일반적으로는 참이나, 필수는 아니다.
    • x.clone().equals(x)
  • 관례상 이 메소드가 반환하는 객체는 super.clone을 호출해서 얻을 수 있다.
  • 또한, Object를 제외한 자기 자신과 모든 상위 클래스가 해당 관례를 만족한다면, x.clone().getClass() == x.getClass()는 참이다.

만약에 관례를 무시하고 생성자를 직접 호출한다면? 이렇게 되면 해당 클래스를 상속 받은 하위 클래스에서 곤란한 점이 생긴다. 정말 각 잡고 모든 클래스에서 일일히 다 생성자를 활용할 것이 아니라면, 피하도록 하자.

그렇다면 이렇게 짜면 될 것 같다.

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

어차피 클래스에 implements Cloneable을 붙일텐데, 왜 catch를 삽입할까? 기본적인 선언 형태가 이렇기 때문이다... 흑흑...

이 클래스가 특정 클래스를 상속 받고 있던, 아니던, super.clone()을 계속 체이닝 하면 결국 Object에서 clone을 실행하게 되고, 해당 Object 객체를 형변환 하여 사용하면 모든 값이 동일한 새로운 객체가 나온다!

그렇지만 이 방법은 좋은 방법이 아니다. 스택 클래스를 만들어보자.

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

    public boolean isEmpty() {
        return size == 0;
    }

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

    private void ensureCapacity() {
        if (elements.length == size)
            elements = Arrays.copyOf(elements, 2 * size + 1);
    }
}

음... 그냥 보기엔 문제가 없어보인다. 그런데 위에서 말했듯이, clone을 하면 모든 값이 동일한 새로운 객체가 나오는데, 즉 element의 참조도 같을 것이다!

즉, clone을 구현할 때는 원본 객체에 아무런 해를 끼치지 않는 동시에 객체의 불변식을 보장해야 한다. 이걸 고려해서 코드를 다시 짜보면...

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

element는 왜 그냥 element.clone()만 하고 말까? 놀랍게도 배열에서 clone을 사용하면 런타임 타입과 컴파일 타입이 일치하는 배열을 반환하기 때문이다! 그래서 배열에서의 clone은 꽤 괜찮은 수단이다. 다만 일반적으로 가변 객체를 참조하는 필드는 final로 선언하는 편인데, 이 겨우 clone을 사용할 수 없다. 그래서 좀 골 때릴 수 있다는 사실...

거기에 한 술 더 떠, 배열이 담고 있는 내용이 참조형 자료라면? 오우... 이 경우에는 위에서 썼던 코드에 한 번 더 재귀적으로 돌려줘야 할 것이다. 점점 코드가 더러워지는 기분...

위에서 object의 clone에 대해,

protected native Object clone() throws CloneNotSupportedException;

이런식으로 예외를 던지도록 정의 했는데, 위에서 재정의한 경우엔 throws 절을 없앴다. 왤까? 그냥 그 메소드를 사용하기 편하도록 하기 위함이다.

결론을 짓자면, Cloneable을 구현하는 모든 클래스는 제대로 사용하기 위해선 clone을 재정의해줘야 한다. 반환 타입은 자신이 되도록 정의하고, super.clone을 호출한 후 필요한 필드를 모두 적절히 수정해 줘야 한다. 만약 내부에 참조를 가리키는 이른바 '깊은 구조'가 있다면 여기에 숨어 있는 모든 가변 객체를 전부 복사해야 할 것이다.

이쯤되면 매우 비효율적이라는 생각이 들 것이다. 그렇다면 그냥 객체를 생성하고 내부 인자만 복사해주면 안 될까? 복사 생성자와 복사 팩토리를 써보자!

public Yee(Yee yee) {...}

이것이 복사 생성자인데, clone을 재정의하는 것 보다 괜찮아 보인다.

이것을 살짝만 수정하면,

public static Yee newInstance(Yee yee) {...}

이런식으로도 사용할 수 있을 것이다.

생성자를 쓰지 않는 희한한 방식인 clone보다 더 안전해보이고, final을 정의한다고 문제가 되지도 않으며, 예외같은건 안 던지고, 형변환도 없기 때문에 이게 훨씬 좋다.

심지어 인터페이스 타입의 인스턴스도 인수로 받을 수 있다. 일반적으로 모든 범용 컬렉션 구현체는 Collection이나 Map 타입을 받는 생성자를 제공하는데, 이를 활용하면 HashSet 객체를 TreeSet으로 쉽게 변경할 수 있다!