[교재 EffectiveJava] 아이템 13. clone 재정의는 주의해서 진행하라

반응형
728x90
반응형

Cloneable

복제해도 되는 클래스임을 명시하는 용도의 인터페이스다. 

 

Cloneable.java
public interface Cloneable { // 실제로 이렇게 비어있음
}

 

하지만 신기한 점이 있는데, clone 메서드가 선언된 곳이 Cloneable이 아닌 Object이다. 

 

Object.java
protected native Object clone() throws CloneNotSupportedException;

 

위 코드를 보면 clone() 메서드는 protected 접근 제한자를 가진다. 이렇게 되면 Cloneable을 구현하는 것만으로는 외부 객체에서 clone 메서드를 호출할 수 없다. 

 

위 방법은 clone() 메서드를 오버라이드하여 public 접근제한자로 변경하여 외부 객체에서 접근 가능하도록 할 수 있다. 자식클래스는 부모클래스의 접근제한자보다 동일하거나 더 넓어야한다.

 

public clone() 메서드
@Override
public Object clone() throws CloneNotSupportedException {
    return super.clone();
}

 

 

Cloneable 인터페이스

public interface Cloneable { // 실제로 이렇게 비어있음
}

이 인터페이스는 메서드가 1개도 없다. 대신 Object의 protected 메서드인 clone() 메서드의 동작방식을 결정한다. Cloneabl을 구현한 클래스의 인스턴스에서 clone() 메서드를 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환하며, 그렇지 않은 클래스의 인스턴스에서 호출하면 CloneNotSupportedException을 호출한다. 

 

인터페이스를 구현한다는 것은 일반적으로 해당 클래스가 그 인터페이스에서 정의한 기능을 제공한다고 선언하는 행위지만, Cloneable의 경우에는 상위 클래스에 정의된 protected 메서드의 동작 방식을 변경한 것이다.

 

 

clone() 메서드의 일반 규약

1) 다음은 참이다.

x.clone() != x

 

2) 다음은 참이다.

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

 

3) 일반적으로 참이지만 필수는 아니다.

x.clone().equals(x)

 

 

clone() 메서드의 잘못된 구현

만약 clone() 메서드가 super.clone()이 아닌, 생성자를 호출해 얻은 인스턴스를 반환하면 이 클래스의 하위 클래스에서 super.clone()을 호출했을때 잘못된 클래스의 객체가 만들어진다. 결국 하위 클래스의 clone() 메서드가 제대로 동작하지 않게된다.

 

예제코드

Item.java
public class Item implements Cloneable {

    private String name;

    /**
     * 이렇게 구현하면 하위 클래스의 clone()이 깨질 수 있다. p78
     * @return
     */
    @Override
    public Item clone() {
        // super.clone()을 쓰지 않고 생성자를 썼음
        // 이렇게하면 규약이 깨짐
        Item item = new Item();
        item.name = this.name;
        return item;
    }
}

 

SubItem.java
public class SubItem extends Item implements Cloneable {

    private String name;

    @Override
    public SubItem clone() {
        // 생성자로 생성한 Item을 리턴하는 clone() 메서드 결과를 SubItem 으로 변환을 못함
        // 상위타입(Item)을 하위타입(SubItem)으로 타입변환이 불가능
        return (SubItem) super.clone();
    }
    
    ...   
}

 

Item.java에서 clone()을 올바르게 구현
public class Item implements Cloneable {

    private String name;

    /**
     * 올바르게 구현한 clone()
     * @return
     */
    @Override
    public Item clone() {
        Item result = null;

        try {
            // 여기선 Item 이라고 생각하지만, SubItem 에서 호출하면 SubItem 타입이다.
            result = (Item) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }

        return result;
    }
}

 

 

clone() 메서드 사용예제

PhoneNumber.java
public final class PhoneNumber implements Cloneable {
    private final short areaCode, prefix, lineNum;

    public PhoneNumber(int areaCode, int prefix, int lineNum) {
        this.areaCode = rangeCheck(areaCode, 999, "지역코드");
        this.prefix   = rangeCheck(prefix,   999, "프리픽스");
        this.lineNum  = rangeCheck(lineNum, 9999, "가입자 번호");
        System.out.println("constructor is called");
    }

    public PhoneNumber(PhoneNumber phoneNumber) {
        this(phoneNumber.areaCode, phoneNumber.prefix, phoneNumber.lineNum);
    }

    private static short rangeCheck(int val, int max, String arg) {
        if (val < 0 || val > max)
            throw new IllegalArgumentException(arg + ": " + val);
        return (short) val;
    }

    // 코드 13-1 가변 상태를 참조하지 않는 클래스용 clone 메서드 (79쪽)
    // 접근제한자는 부모클래스보다 같거나 넓은 범위의 접근제한자를 가져야한다.
    // Object의 하위타입 PhoneNumber 리턴할 수 있다.(구체적잍 타입 리턴하면 클라이언트에서 타입캐스팅 안해도됨)
    // protected 로 하면 하위클래스만 접근 가능한데, 거의 외부에서 접근하므로 public이 맞다.
    @Override
    public PhoneNumber clone() {
        try {
            // super.clone()을 사용해야한다.
            // 여기서 생성자를 사용하면 안된다.
            return (PhoneNumber) super.clone();
        } catch (CloneNotSupportedException e) { // checkedException (Cloneable 구현하지 않았을 경우)
            throw new AssertionError();  // 일어날 수 없는 일이다.
        }
    }

    // 상위클래스의 clone() 모습
//    @Override
//    protected Object clone() throws CloneNotSupportedException {
//        return super.clone();
//    }

    public static void main(String[] args) {
        PhoneNumber pn = new PhoneNumber(707, 867, 5309);
        Map<PhoneNumber, String> m = new HashMap<>();
        m.put(pn, "제니");

        /* 생성자로 만들진 않음, 결국엔 Object의 clone() 호출 */
        PhoneNumber clone = pn.clone();
        System.out.println(m.get(clone));

        System.out.println(clone != pn); // 반드시 true
        System.out.println(clone.getClass() == pn.getClass()); // 반드시 true
        System.out.println(clone.equals(pn)); // true가 아닐 수도 있다.
    }

    @Override public boolean equals(Object o) {
        if (o == this)
            return true;
        if (!(o instanceof PhoneNumber))
            return false;
        PhoneNumber pn = (PhoneNumber)o;
        return pn.lineNum == lineNum && pn.prefix == prefix
                && pn.areaCode == areaCode;
    }

    @Override public int hashCode() {
        int result = Short.hashCode(areaCode);
        result = 31 * result + Short.hashCode(prefix);
        result = 31 * result + Short.hashCode(lineNum);
        return result;
    }

    /**
     * 이 전화번호의 문자열 표현을 반환한다.
     * 이 문자열은 "XXX-YYY-ZZZZ" 형태의 12글자로 구성된다.
     * XXX는 지역 코드, YYY는 프리픽스, ZZZZ는 가입자 번호다.
     * 각각의 대문자는 10진수 숫자 하나를 나타낸다.
     *
     * 전화번호의 각 부분의 값이 너무 작아서 자릿수를 채울 수 없다면,
     * 앞에서부터 0으로 채워나간다. 예컨대 가입자 번호가 123이라면
     * 전화번호의 마지막 네 문자는 "0123"이 된다.
     */
    @Override public String toString() {
        return String.format("%03d-%03d-%04d",
                areaCode, prefix, lineNum);
    }
}

이렇게 얻은 객체는 원본의 완벽한 복제본일 것이다. 클래스에 정의된 모든 필드는 원본 필드와 똑같은 값을 갖는다. 

 

1) Cloneable 구현 

public final class PhoneNumber implements Cloneable {
    ...
}

위 PhoneNumber 클래스 선언에 Cloneable을 구현해야한다. Object의 clone() 메서드는 Object를 반환하지만 PhoneNumber의 clone 메서드는 PhoneNumber를 반환하게 했다. 

 

2) 형변환 

@Override
public PhoneNumber clone() {
    try {
        // super.clone()을 사용해야한다.
        // 여기서 생성자를 사용하면 안된다.
        return (PhoneNumber) super.clone();
    } catch (CloneNotSupportedException e) { // checkedException (Cloneable 구현하지 않았을 경우)
        throw new AssertionError();  // 일어날 수 없는 일이다.
    }
}

결국, 재정의한 메서드의 반환 타입은 사위 클래스의 메서드가 반환하는 타입의 하위 타입일 수 있다. 이 방식으로 클라이언트가 형변환하지 않아도 되게끔 해주자. 

 

3) try~catch 블록으로 CloneNotSupportedException 처리

@Override
public PhoneNumber clone() {
    try {
        // super.clone()을 사용해야한다.
        // 여기서 생성자를 사용하면 안된다.
        return (PhoneNumber) super.clone();
    } catch (CloneNotSupportedException e) { // checkedException (Cloneable 구현하지 않았을 경우)
        throw new AssertionError();  // 일어날 수 없는 일이다.
    }
}

 

 

가변객체를 참조하는 경우

클래스가 가변객체를 참조할때 clone() 메서드를 사용한다면 큰 문제가 발생할 수 있다.

 

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

    // 코드 13-2 가변 상태를 참조하는 클래스용 clone 메서드
    // TODO stack -> elementsS[0, 1]
    // TODO copy -> elementsC[0, 1]
    // TODO elementsS[0] == elementsC[0]

    @Override public Stack clone() {
        try {
            Stack result = (Stack) super.clone();
            /*
            호출 안하면?
            다른 두 인스턴스 stack, copy -> 동일한 elements 를 참조하게된다.
            다른 두 인스턴스에서 동일한 배열을 쓰게됨

            elements.clone()을 해서 배열을 복사해야 복사본, 원본 인스턴스가 각각의 elements를 가지게된다.

            (얕은복사)
            stack -> elementsS[0, 1]
            copy -> elementsC[0, 1]

            // 배열만 새로 만들고. 안에 있는 인스턴스들은 동일하게 참조한다. (여전히 위험하긴한다.)
            elementsS[0] == elementsC[0]
             */
//            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);
    }
    
    // clone이 동작하는 모습을 보려면 명령줄 인수를 몇 개 덧붙여서 호출해야 한다.
    public static void main(String[] args) {
        Object[] values = new Object[2];
        values[0] = new PhoneNumber(123, 456, 7890);
        values[1] = new PhoneNumber(321, 764, 2341);

        // 원본
        Stack stack = new Stack();
        for (Object arg : values)
            stack.push(arg);

        // 복사본
        Stack copy = stack.clone();

        System.out.println("pop from stack");
        while (!stack.isEmpty())
            System.out.println(stack.pop() + " ");

        System.out.println("pop from copy");
        while (!copy.isEmpty())
            System.out.println(copy.pop() + " ");

        System.out.println(stack.elements[0] == copy.elements[0]);
    }
}

 

clone() 메서드가 super.clone()의 결과를 그대로 반환한다면, 반환된 Stack 인스턴스의 size 필드는 올바른 값을 갖겠지만, elements 필드는 원본 Stack 인스턴스와 똑같은 배열을 참조하게된다. 이때 원본이나 복제본 중 하나를 수정한다면 다른 하나도 수정되어 불변식을 해치게된다. 만약 Stack 클래스의 하나뿐인 생성자를 호출했다면 이러한 상황은 발생할 수 없다.

 

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

 

위 Stack 클래스의 경우에는 스택 내부 정보를 복사해야한다. elements 배열의 clone을 재귀적으로 호출해주자.

 

Stack.java의 가변 상태를 참조하는 클래스용 clone 메서드
 @Override public Stack clone() {
    try {
        Stack result = (Stack) super.clone();
        result.elements = elements.clone();
        return result;
    } catch (CloneNotSupportedException e) {
        throw new AssertionError();
    }
}

 

배열의 clone은 런타임 타입과 컴파일 타입 모두가 원본 배열과 똑같은 배열을 반환한다. 

 

 

elements 필드가 final이였다면?

final 필드에는 새로운 값 할당이 불가능하므로 앞의 방식은 작동하지 않는다. 이는 Cloneable 아키텍처는 '가변 객체를 참조하는 필드는 final로 선언하라'는 일반 용법과 충돌한다. 그래서 복제할 수 있는 클래스를 만들기 위해 일부 필드에서 final 한정자를 제거해야할 수도 있다.

 

▶ final로 선언한다면 위 Stack.java 예제에서는 아래 예제를 수행시킬 수 없다.

 public Stack() {
    this.elements = new Object[DEFAULT_INITIAL_CAPACITY];
}

 

 

CloneNotSupportedException

public class CloneNotSupportedException extends Exception {
    private static final long serialVersionUID = 5195511250079656443L;

    /**
     * Constructs a <code>CloneNotSupportedException</code> with no
     * detail message.
     */
    public CloneNotSupportedException() {
        super();
    }

    /**
     * Constructs a <code>CloneNotSupportedException</code> with the
     * specified detail message.
     *
     * @param   s   the detail message.
     */
    public CloneNotSupportedException(String s) {
        super(s);
    }
}

1) CheckedException

Exception을 상속하고 있다. CheckedException 이기 때문에 클라이언트에서 이 오류를 잡아서 처리해줘야한다.

public class CloneNotSupportedException extends Exception {
    ...
}

사실, CloneNotSupportedException가 CheckedException이여야했던 이유는 이펙티브 자바의 저자 조차도 이해할 수 없다고 한다. 클라이언트에서 해당 에러를 잡아서 처리할 행위가 없는데 CheckedException이기 때문에 호출을 하는 코드마다 해당 오류를 처리해줘야한다. UncheckedException이면 더 좋았을거라는 생각이 든다.

 

 

상속용 클래스는 Cloneable 구현하지않기

부모클래스 Shape.java
public abstract class Shape implements Cloneable {

    private int area;

    public abstract int getArea();
}

일반적으로 상속용 클래스에 Cloneable 인터페이스 사용을 권장하지 않는다. 이 클래스를 확장하려는 프로그래머는 자식클래스가 어떻게 clone() 메서드를 구현해야할지 고민을 해야하는 부담을 주기 때문이다.

 

기본 clone() 구현
public abstract class Shape implements Cloneable {

    private int area;

    public abstract int getArea();


    /**
     * p84, 부담을 덜기 위해서는 기본 clone() 구현체를 제공하여,
     * Cloenable 구현 여부를 서브 클래스가 선택할 수 있다.
     * (하위클래스가 굳이 구현을 안하게끔 해준다.)
     * @return
     * @throws CloneNotSupportedException
     */
    @Override
    public Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
}

부담을 덜어주기 위해서 기본 clone() 구현체를 제공한다. 이렇게하면 Cloneable 구현 여부를 자식 클래스가 선택할 수 있다.

 

또다른 방법

clone을 동작하지 않게 구현해놓고 하위 클래스에서 재정의하지 못하게 할 수 있다.

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

 

 

복사 생성자

Cloneable을 구현하는 것 대신, 복사 생성자와 복사 팩터리라는 더 나은 객체 복사 방식을 제공할 수 있다. 복사 생성자란 단순히 자신과 같은 클래스의 은서튼스를 인수로 받는 생성자를 말한다.

// 복사 생성자
public PhoneNumber(PhoneNumber phoneNumber) {
    this(phoneNumber.areaCode, phoneNumber.prefix, phoneNumber.lineNum);
}

이 방법은 규약에 기대지 않고, 정상적인 final 필드 용법과도 충돌하지 않는다. 불필요한 검사 예외를 던지지 않고, 형변환도 필요하지 않다. 또한 해당 클래스가 구현한 인터페이스 타입의 인스턴스로도 받을 수 있는 장점이 있다.

 

 

 

반응형

Designed by JB FACTORY