본문 바로가기
Books/Effective-Java 3판

13. clone 재정의는 주의해서 진행하라(1)

by 두두리안 2021. 10. 5.
728x90

메서드 하나 없는 Cloneable 인터페이스는 대체 무슨 일을 할까?

- Object의 protected 메서드인 clone의 동작 방식을 결정한다

- Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환하며,

- 그렇지 않은 클래스의 인스턴스에서 호출하면 CloneNotSupportedException을 던진다

- 인터페이스를 상당히 이례적으로 사용한 예이니 따라하지는 말자


인터페이스를 구현한다는 것은 일반적으로 해당 클래스가 그 인터페이스에서 정의한 기능을 제공한다고 선언하는 행위

- 그런데 Cloneable의 경우에는 상위 클래스에 정의된 protected 메서드의 동작 방식을 변경한 것이다.


실무에서는 Cloneable을 구현한 클래스는 clone 메서드를 public으로 제공하며, 

사용자는 당연히 복제가 제대로 이뤄지리라 기대한다

- 이 기대를 만족시키려면 그 클래스와 모든 상위 클래스는 복잡하고, 강제할 수 없고 

- 허술하게 기술된 프로토콜을 지켜야만 하는데,

- 그결과로 깨지기 쉽고, 위험하고, 모순적인 메커니즘이 탄생한다

- 생성자를 호출하지 않고도 객체를 생성할 수 있게 되는 것이다


Clone 메서드의 일반 규약은 허술하다. Object 명세에서 가져온 다음 설명해보자

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

x.clone()!= x

또한 다음 식도 참이다

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

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

x.clone(). equals(x)

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

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

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

- 강제성이 없다는 점만 빼면 생성자 연쇄와 살짝 비슷한 메커니즘이다

- clone 메서드가 super.clone이 아닌, 생성자를 호출해 얻은 인스턴스를 반환해도 컴파일러는 불평하지 않을 것이다

- 하지만 이 클래스의 하위 클래스에서 super.clone을 호출한다면 잘못된 클래스의 객체가 만들어져,

- 결국 하위 클래스의 clone 메서드가 제대로 동작하지 않게 된다

- 'clone'을 재정의한 클래스가 final이라면 걱정해야 할 하위 클래스가 없으니 이 관례는 무시해도 안전하다

- 하지만 final 클래스의 clone 메서드가 super.clone을 호출하지 않는다면 Cloneable을 구현할 이유도 없다

- Object의 clone 구현의 동작 방식에 기댈 필요가 없기 때문이다


제대로 동작하는 clone 메서드를 가진 상위 클래스를 상속해 Cloneable을 구현하고 싶다고 해보자!

- 먼저 super.clone을 호출한다

- 이렇게 얻은 객체는 원본의 완벽한 복제본이다

- 클래스에 정의된 모든 필드는 원본 필드와 똑같은 값을 갖는다

- 모든 필드가 기본 타입이거나 불변 객체를 참조한다면 이 객체는 완벽히 우리가 원하는 상태다


코드 13-1 가변 상태를 참조하지 않는 클래스용 clone 메서드

// 코드 13-1 가변 상태를 참조하지 않는 클래스용 clone 메서드 (79쪽)
    @Override public PhoneNumber clone() {
        try {
            return (PhoneNumber) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();  // 일어날 수 없는 일이다.
        }

- 메서드가 동작하게 하려면 PhoneNumber의 클래스 선언에 Cloneable을 구현한다고 추가

- Object의 clone 메서드는 Object를 반환하지만 PhoneNumber의 clone 메서드는 PhoneNumber를 반환하게 했다.

- 자바가 공변 반환 타이핑을 지원하니 이렇게 하는 것이 가능하고 권장하는 방식이다

- 재정의한 메서드의 반환 타입은 상위 클래스 메서드가 반환하기 전에 PhoneNUmber로 형 변환하였다


super.clone 호출을 try-catch 블록으로 감싼 이유는?

- Object의 clone 메서드가 검사 예외인 CloneNotSupportedException을 던지도록 선언되었기 때문이다

- PhoneNumber가 Cloneable을 구현하니, 우리는 super.clone이 성공할 것임을 안다

- 이 거추장스러운 코드는 CloneNotSupportedException이 사실은 비검사 예외였어야 했다는 신호다


클래스가 가변 객체를 참조하는 순간 재앙으로 돌변한다

// Stack의 복제 가능 버전 (80-81쪽)
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);
    }
}

- clone 메서드가 단순히 super.clone의 결과를 그대로 반환한다면 어떻게 될까?

- 반환된 Stack 인스턴스의 size 필드는 올바른 값을 갖겠지만, elements 필드는 원본 Stack 인스턴스와 똑같은 배열을 참조

- 원본이나 복제본 중 하나를 수정하면 다른 하나도 수정되어 불변식을 해친다

- 결국에는 NullPointerException을 던질 것이다

Clone 메서드는 사실상 생성자와 같은 효과를 낸다.

즉, clone은 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야 한다


코드 13-2 가변 상태를 참조하는 클래스용 clone 메서드

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

- Stack의 clone 메서드는 제대로 동작하려면 스택 내부 정보를 복사해야 하는데,

- 가장 쉬운 방법은 elements 배열의 clone을 재귀적으로 호출해주는 것이다

- elements.clone의 결과를 Object []로 형 변환할 필요는 없다

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

- 따라서 배열을 복제할 때는 배열의 clone 메서드를 사용하라고 권장한다


elements 필드가 final이었다면 앞서의 방식은 작동하지 않는다 -> final 필드에는 새로운 값을 할당할 수 없기 때문이다

Cloneable 아키텍처는 '가변 객체를 참조하는 필드는 final로 선언하라'는 일반 용법과 출동한다

- 단, 원본과 복제된 객체가 그 가변 객체를 공유해도 안전하다면 괜찮다.

- 복제할 수 있는 클래스를 만들기 위해 일부 필드에서 final 한정자를 제거해야 할 수도 있다


clone을 재귀적으로 호출하는 것 만으로는 충분하지 않을 때도 있다. 이번에는 해시 테이블용 clone 메서드를 생각해보자

- 해시테이블 내부는 버킷들의 배열이고, 각 버킷은 키-값 쌍을 담는 연결 리스트의 첫 번째 엔 트릴 참조한다

- 그리고 성능을 위해 java.util.LinkedList 대신 직접 구현한 경량 연결 리스트를 사용하겠다

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

 

728x90