코딩하는 털보

이펙티브 자바, 아이템 13. clone 재정의는 주의해서 진행하라 본문

Book/이펙티브 자바

이펙티브 자바, 아이템 13. clone 재정의는 주의해서 진행하라

이정인 2021. 8. 28. 13:34

이펙티브 자바, 아이템 13. clone 재정의는 주의해서 진행하라

Java 제공 인터페이스 Cloneable는 아무 메서드도 가지고 있지 않지만 이 인터페이스를 구현하는 인스턴스에서는 Object의 clone 메서드의 동작이 바뀐다.
Cloneable을 구현한 인스턴스에서 clone 메서드를 호출하게 되면, 해당 객체를 복사한 객체를 반환하게 된다.

Object의 clone 메서드는 protected이기 때문에 외부 객체에서 사용하려면 재정의가 필요하다.
Object clone()은 Object 타입을 반환하기 때문에 공변 반환 타입을 통해서 Object 대신 재정의하는 클래스 타입으로 캐스팅하여 반환하도록 재정의하는게 좋다.
공변 반환 타입 : 재정의한 메서드의 반환 타입은 상위 클래스의 메서드가 반환하는 타입의 하위 타입일 수 있다.

@Override
protected PhoneNumber clone() throws CloneNotSupportedException {
    return (PhoneNumber) super.clone();
}

이렇게 반환하면 clone을 사용하는 클라이언트는 추가적인 형변환을 하지 않아도 된다.

사실 위의 예에서 사용한 PhoneNumber 클래스의 모든 필드는 프리미티브 타입인데, 만약 클래스가 예시처럼 기본 타입이나 불변 객체를 참조하는게 아닌 가변 객체를 참조하고 있으면 문제가 생길 수 있다. (모든 필드가 기본 타입이나 불변 객체일지라도 일련번호나 고유 ID 같은 필드는 변경하면서 복사하는게 좋다.)
아래와 같은 Cloneable 전화번호부 클래스가 있다.

public class PhoneBook implements Cloneable{

    @Getter
    private Map<PhoneNumber, String> book = new HashMap<>();

    public void add(PhoneNumber pn, String str) {
        book.put(pn, str);
    }

    public void delete(PhoneNumber pn) {
        book.remove(pn);
    }

    @Override
    protected PhoneBook clone() throws CloneNotSupportedException {
        return (PhoneBook) super.clone();
    }
}

전화번호부 객체에서 clone을 사용하면 객체가 복사되는데, 복사본은 다른 객체이기때문에 원본 객체에 영향을 주지 않을거라고 생각되지만 복사본이 참조하는 필드를 변경하면 원본 객체도 같이 변경될 수 있다.

    @Test
    public void createPhoneNumber() throws CloneNotSupportedException {
        PhoneNumber pn1 = new PhoneNumber(123,456,7890);
        PhoneNumber pn2 = new PhoneNumber(123,456,7891);
        PhoneNumber pn3 = new PhoneNumber(123,456,7892);

        PhoneBook book = new PhoneBook();
        book.add(pn1, "jilee");
        book.add(pn2, "bongu");
        book.add(pn3, "dongu");

        PhoneBook bookClone = book.clone();
        bookClone.delete(pn1);

        //false, 변경은 clone 객체에서 했지만 원본 객체도 변경되었다.
        System.out.println(book.getBook().containsKey(pn1));
    }

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

문제를 해결하려면 clone 대신 생성자를 사용하거나 참조하는 가변 객체에 대한 복사본을 복사 객체에 넣어주고 반환하는 것이다.

    @Override
    protected PhoneBook clone() throws CloneNotSupportedException {
        PhoneBook result = (PhoneBook) super.clone();

        //여기서 clone 메서드는 HashMap 타입에서만 사용할 수 있기 때문에 캐스팅을 사용했다.
        //Map이 아닌 배열이라면 이런 과정이 필요없다.
        HashMap<PhoneNumber, String> book = (HashMap<PhoneNumber, String>) this.book;
        result.book = (Map<PhoneNumber, String>) book.clone();
        return result;
    }

Collection이나 Map은 clone 메서드를 사용하기엔 좀 애매하다. Map의 copyOf()는 불변 클래스를 반환하며 예시에서는 PhoneNumber의 필드가 final이므로 문제가 없었지만 final이 아니었다면 결국 book에서 key로 사용하는 PhoneNumber 객체는 복사본과 원본 객체에서 공유하게 된다.
반면 배열은 clone 기능을 제대로 사용하는 유일한 예라고 할 수 있다.
한편 참조 객체 필드가 final이라면 새로 대입할 수 없으므로 위와 같은 방식은 사용할 수 없다.

요약
Cloneable의 문제를 되짚어보면, 새로운 인터페이스를 만들 때나 새로운 클래스를 만들 때 Cloneable을 확장하거나 구현해서는 안 된다.
Cloneable을 구현해야 한다면 클래스는 clone 메서드를 꼭 재정의해야한다.
재정의 할 때는 public으로 클래스 타입 반환하면서 가장 먼저 super.clone을 호출한 후 필요한 필드를 전부 적절히 수정한다.
일반적으로 적절한 수정은 그 객체 내부의 '깊은 구조'에 숨어있는 모든 가변 객체를 복사하고, 복제본의 객체 참조가 이 복사된 객체들을 가리키게 하는 것이다.
이러한 방식은 주로 clone을 재귀적으로 호출하여 구현하지만, 이 방법이 항상 최선인 것은 아니다.

Cloneable을 꼭 구현해야 하는 경우나 Cloneable을 구현한 클래스를 확장하는게 아니라면 복사 생성자와 복사 팩터리라는 더 나은 객체 복사 방식을 제공할 수 있다.

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

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

복사 생성자 및 복사 팩터리는 Cloneable/clone 방식보다 나은 면이 많다.
Cloneable/clone은 언어 모순적이다. Cloneable/clone을 사용하면 생성자를 쓰지 않고 객체가 생성된다.
Cloneable/clone의 규약은 엉성하다.
Cloneable/clone은 정상적인 final 필드 용법과 충돌한다.
Cloneable/clone은 재정의에서 불필요한 검사 예외를 던진다.
Cloneable/clone은 재정의에서 형변환이 필요하다.

또한 복사 생성자를 기반으로 변환 생성자, 변환 팩터리도 지원한다.

public class Yum implements Tool {
    ...
}
public Yum(Tool yum) { ... };

변환 생성자 나 변환 팩터리를 이용하면 클라이언트는 원본의 구현 타입에 얽매이지 않고 복제본의 타입을 직접 선택할 수 있다.
예를들어 HashSet 객체 s를 TreeSet 타입으로 복제할 수 있다 new TreeSet<>(s). clone으로는 불가능!

Comments