Effective Java

[Effective Java] 아이템13 - clone 재정의는 주의해서 진행하라

돌에 2022. 1. 25. 23:07
Note that this interface does not contain the clone method. Therefore, it is not possible to clone an object merely by virtue of the fact that it implements this interface. Even if the clone method is invoked reflectively, there is no guarantee that it will succeed.

 

Cloneable 인터페이스

 

: Object의 protected 메서드인 clone의 동작 방식을 결정함

 

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

 

clone 메서드는 강제성이 없다는 점만 빼면 생성자 연쇄(constructor chaining)와 비슷한 메커니즘이라고 볼 수 있음

즉, clone 메서드가 super.clone()이 아닌, 생성자를 호출해 얻은 인스턴스를 반환해도 컴파일러는 상관없을 것

 

하지만, 이 클래스의 하위 클래스에서 super.clone()을 호출한다면 잘못된 클래스의 객체가 만들어지므로, 하위 클래스의 clone 메서드가 제대로 동작하지 않게 됨

→ clone을 재정의한 클래스가 final이라면 이 관례는 무시해도 안전함

 

모든 필드가 기본 타입이거나 불변 객체를 참조하는 클래스의 경우

 

@Override public PhoneNumber clone() {
	try {
		return (PhoneNumber) super.clone();
	} catch (CloneNotSupportedException e) {
		throw new AssertionError(); // 비검사 예외(upchecked excaption)처리 하는 것이 더 좋음
	}
}

 

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

 

가변 객체를 참조하는 클래스의 경우

 

public class Stack {
	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 ensureCapacityf) {
		if (elements.length = size)
			elements = Arrays.copyOf(elements, 2 * size + 1);
	}
}

 

위 클래스를 복제할 수 있도록 만들 때 clone 메서드가 super.clone()의 결과를 그대로 반환한다면,

Stack 인스턴스의 size 필드는 올바른 값을 갖겠지만, elements 필드는 원본 Stack 인스턴스와 같은 배열을 참조할 것임

불변식을 해치므로 프로그램이 이상하게 동작하거나 NullPointerException을 던질 것임

 

Stack 클래스의 생성자를 호출한다면 위 상황은 절대 일어나지 않음

clone 메서드는 사실상 생성자와 같은 효과를 냄

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

 

가변 객체를 복제하는 방법 3가지

 

#1. elements 배열의 clone을 재귀적으로 호출

: Stack 내부 정보를 복사하는 가장 쉬운 방법

 

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

 

배열의 clone은 런타임 타입과 컴파일타임 타입 모두 원본 배열과 같은 배열을 반환함

배열을 복제할 때는 배열의 clone 메서드를 사용하는 것이 좋음

 

한편, elements 필드가 final이었다면 위 방식은 동작하지 않음

Cloneable 아키텍처는 '가변 객체를 참조하는 필드는 final로 선언하라.'는 일반 용법과 충돌함

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

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

 

 

#2. 해시테이블용 clone 메서드

 

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;
		}
	}
    
    @Override public HashTable clone() {
		try {
			HashTable result = (HashTable) super.clone();
			result.buckets = buckets.clone();
			return result;
		} catch (CloneNotSupportedException e) {
			throw new AssertionError();
		}
	}
	...// 나머지 코드는 생략
}

 

Stack에서처럼 단순히 버킷 배열의 clone을 재귀적으로 호출하면,

복제본의 버킷 배열은 원본과 같은 연결 리스트를 참조하게 됨

각 버킷을 구성하는 연결 리스트를 복사해야 함

 

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

 

위 방법은 간단하며, 버킷이 너무 길지 않다면 잘 작동함

하지만 재귀 호출 때문에 리스트의 원소 수만큼 스택 프레임을 소비해, 리스트가 길면 스택 오버플로를 일으킬 위험이 있기 때문에 그다지 좋지 않음

→ 재귀 호출 대신 반복자를 사용하여 순회하는 방향으로 수정해야 함

 

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

 

 

#3. 고수준 API 사용

 

1. super.clone을 호출하여 얻은 객체의 모든 필드를 초기 상태로 설정

2. 원본 객체의 상태를 다시 생성하는 고수준 메서드들을 호출

 

ex) HashTable

1. buckets 필드를 새로운 버킷 배열로 초기화

2. 원본 테이블에 담긴 모든 키-값 쌍 각각에 대해 복제본 테이블의 put(key, value) 메서드를 호출해 복사

 

고수준 API를 활용해 복제하면 간단하지만, 저수준에서 바로 처리할 때보다 느림

Cloneable 아키텍처의 기조가 되는 필드 단위 객체 복사를 우회하기 때문에 어울리지 않는 방식이기도 함

 

참고 사항

 

Object의 clone 메서드는 CloneNotSupportedException을 던진다고 선언했지만,

재정의한 public clone 메서드에서는 throws 절을 없애야 함

 

상속용 클래스는 Cloneable을 구현하면 안 됨

1. 하위 클래스에서 선택하도록 해야 함

2. clone을 동작하지 않게 구현해놓고 하위 클래스에서 재정의하지 못하게 할 수 있음 (밑 코드 참고)

 

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

 

Cloneable을 구현한 스레드 안전 클래스를 작성할 때

clone 메서드 역시 동기화해줘야 함

→ super.clone 호출 외에 다른 할 일이 없더라도 clone을 재정의하고 동기화해줘야 함

 

 

Cloneable을 구현하는 모든 클래스는 clone을 재정의해야 한다!

 

그런데 위의 모든 작업이 꼭 필요한 걸까?

 

Cloneable을 이미 구현한 클래스를 확장한다면 어쩔 수 없이 clone을 잘 작동하도록 구현해야 함

그렇지 않은 상황에서는 복사 생성자복사 팩터리라는 더 나은 객체 복사 방식을 제공할 수 있음

 

복사 생성자

: 단순히 자신과 같은 클래스의 인스턴스를 인수로 받는 생성자

 

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

 

 

복사 팩터리

: 복사 생성자를 모방한 정적 팩터리

 

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

 

복사 생성자와 복사 팩터리의 장점

  • 객체 생성 매커니즘(생성자를 쓰지 않는 방식)을 사용하지 않음
  • 엉성하게 문서화된 규약에 기대지 않음
  • 정상적인 final 필드 용법과도 충돌하지 않음
  • 불필요한 검사 예외를 던지지 않음
  • 형변환도 필요치 않음

 

인터페이스 기반 복사 생성자와 복사 팩터리

= 변환 생성자(conversion constructor)와 변환 팩터리(conversion factory)

 

해당 클래스가 구현한 인터페이스 타입의 인스턴스를 인수로 받을 수 있음

→ 원본의 구현 타입의 얽매이지 않고 복제본의 타입을 직접 선택할 수 있음

 

ex) HashSet 객체 s를 TreeSet 타입으로 복제할 수 있음

clone으로는 불가능한 이 기능을 변환 생성자로는 new TreeSet<>(s)로 처리 가능

 

새로운 인터페이스를 만들 때는 절대 Cloneable을 확장해서는 안 되며, 새로운 클래스도 이를 구현해서는 안 된다. final 클래스라면 Cloneable을 구현해도 위험이 크지 않지만, 성능 최적화 관점에서 검토한 후 문제가 없을 때만 허용해야 한다. 기본 원칙은 복제 기능은 생성자와 팩터리를 이용하는 게 최고라는 것이다. 단, 배열만은 clone 메서드 방식이 가장 깔끔하고 합당한 예외라 할 수 있다.