Cloneable

Cloneable은 복제해도 되는 클래스임을 명시하는 용도의 mixin interface(아이템 20)지만, 의도한 목적을 제대로 이루지 못했다.

 

가장 큰 문제는 clone 메서드가 선언된 곳이 Cloneable이 아닌 Object이고, 그마저도 protected라는 데 있다.

그래서 Cloneable을 구현하는 것만으로는 외부 객체에서 clone 메서드를 호출할 수 없다.

리플렉션(아이템 65)을 사용하면 가능하지만, 100% 성공하는 것도 아니다. (해당 객체가 접근 허용된 clone 메서드를 제공한다는 보장X)

 

메서드 하나 없는 Cloneable 인터페이스는 Object의 protected 메서드인 clone의 동작 방식을 결정한다.

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

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

 

Object.clone

Cloneable을 구현한 클래스는 clone 메서드를 public으로 제공하며, 복제생성지를 호출하지 않고도 객체를 생성할 수 있게 되는 것이다.

생성지를 호출하지 않고도 객체를 생성할 수 있게 되는 것이기 때문에 위험하다고 할 수 있다.

 

위 Object.clone에서 기술된 매커니즘을 자세히 보자.

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

어떤 객체 x에 대해 다음 식은 참이다. x.clone() != x

또한 다음 식도 참이다.
x.clone().getClass() = x.getClass()

다음 식도 일반적으로 참이지만, 역시 필수는 아니다.
x.clone() .equals(x)

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

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

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

단, 주의할 점은 어떤 클래스의 하위 클래스에서 super.clone을 호출한다면 잘못된 클래스의 객체가 만들어져, 결국 하위 클래스의 clone 메서드가 제대로 동작하지 않게 된다는 점이다.

 

무슨 말이냐면

클래스 B가 클래스 A를 상속할 때. 하위 클래스인 B의 clone은 B 타입 객체를 반환해야 한다.

그런데 A의 clone이 자신의 생성자, 즉 new A()로 생성한 객체를 반환한다면 B의 clone도 A 타입 객체를 반환할 수밖에 없다.

즉, super.clone을 연쇄적으로 호출하도록 구현해두면 clone이 처음 호출된 상위 클래스의 객체가 만들어진다.

 

무슨말일까? 아래와 같이 테스트 코드를 짜 본 후 Child를 호출해보자.

super.clone에서 상위 객체인 Parent를 생성 하는 것이 아니라 처음 호출 된 Child를 생성하게 된다.

package com.example.sypark9646.item13;

import lombok.Getter;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@ToString
@Getter
public class Parent implements Cloneable {

		private String parentField;

		public Parent(String parentField) {
				this.parentField = parentField;
		}

		@Override
		public Parent clone() {
				try {
						Object clone = super.clone();
						log.info("class Parent: " + clone.getClass());
						return (Parent) clone;
				} catch (CloneNotSupportedException e) {
						throw new AssertionError();
				}
		}
}
package com.example.sypark9646.item13;

import lombok.Getter;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Getter
public class Child extends Parent implements Cloneable { // cloneable 는 생략해도 됨

		public int childField;

		public Child(String parentField, int childField) {
				super(parentField);
				this.childField = childField;
		}

		@Override
		public Child clone() {
				// 상위 Parent class 에서 CloneNotSupportedException 을 잡아줌
				Object clone = super.clone();
				log.info("class Child: " + clone.getClass());
				return (Child) clone;
		}

		@Override
		public String toString() {
				return "Child(parentField=" + this.getParentField() + ", childField=" + this.getChildField() + ")";
		}
}
package com.example.sypark9646.item13;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class CloneChainingTest {

		@Test
		@DisplayName("clone test")
		void superCloneAndInteritenceTest() {
				Parent parent = new Parent("parent");
				Child child = new Child("parent", 1);
				System.out.println("parent: " + parent);
				System.out.println("child: " + child);

				System.out.println("[parent clone]");
				Parent parentClone = parent.clone();
				System.out.println("parent clone: " + parentClone); // parent: Parent(parentField=parent)

				System.out.println("[child clone]");
				Child childClone = child.clone();
				System.out.println("child clone: " + childClone); // child: Child(parentField=parent, childField=1)

				Assertions.assertSame(parent.getClass(), parentClone.getClass());
				Assertions.assertSame(child.getClass(), childClone.getClass());
				Assertions.assertTrue(parentClone.getParentField().hashCode() == childClone.getParentField().hashCode()); // 동일한 참조값
		}
}

 

아래 스택오버 플로우에서 더 자세한 내용을 확인할 수 있다.

stackoverflow.com/questions/11905630/java-super-clone-method-and-inheritance

 

Java: super.clone() method and inheritance

I have a quick question regarding the clone() method in Java, used as super.clone() in regard to inheritance - where I call the clone() method in the parent class all the way up from the button. The

stackoverflow.com

사실 테스트를 짜 봐도 super.clone을 호출했을 때 Parent의 clone을 사용하고 있고,

Child를 만드는건데 당연히 Child만 만들어져야 하는 것 아닌가? 싶기도 하고... 잘 와닿지 않아 스터디 팀원분들께 질문 드렸다.

github.com/dolly0920/Effective_Java_Study/issues/33

 

[ITEM 13 Question] - 상속 관계에서 super.clone 호출에 대한 문제점 · Issue #33 · dolly0920/Effective_Java_Study

item13 p.78 (옮긴이) 클래스 B가 클래스 A를 상속할 때. 하위 클래스인 B의 clone은 B 타입 객체를 반환해야 한다. 그런데 A의 clone이 자신의 생성자, 즉 new A(..)로 생성한 객체를 반환한다면 B의 clone도 A

github.com

 

정리하자면 위 예시처럼 parent class에 object.clone을 사용했을 땐 문제가 되지 않지만,

예시로 주신 코드와 같이 parent class를 clone 할 때 아래와 같이 생성자를 사용하게 되면 문제가 된다.

package com.example.sypark9646.item13;

import lombok.Getter;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@ToString
@Getter
public class Parent implements Cloneable {

		private String parentField;

		public Parent(String parentField) {
				this.parentField = parentField;
		}

		@Override
		public Parent clone() {
				Parent clone = new Parent(this.getParentField());
				log.info("class Parent: " + clone.getClass());
				return clone;
		}
}

요 부분만 바꿨을 뿐인데, Child clone에서는 parent 클래스의 다운캐스팅이 일어나게 되고, 이 부분에서 런타임 에러가 발생하게 되는 것..


 

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

class phoneNumber implements Cloneable {

	...
	
	@Override 
	public PhoneNumber clone() {
		try { 
			// Object의 clone 메서드는 Object를 반환하지만 Phone Number의 clone 메서드는 PhoneNumber를 반환하게 했다
			return (PhoneNumber) super.clone(); 
		} catch (CloneNotSupportedException e) {
			throw new AssertionError(); 
		}
	}
}

 

여기서 super.clone 호출을 try-catch 블록으로 감싼 이유는 Object의 clone 메서드가 검사 예외(checked exception)인 CloneNotSupportedException을 던지도록 선언되었기 때문이다. (CloneNotSupported Exception이 사실은 비 검사 예외 (unchecked exception) 였어야 했다. 아이템71)

 

 

만약 가변 객체에 대한 clone을 만들고 싶다면 어떻게 될까?

참조변수의 경우 동일한 값을 가지기 때문에 한 쪽이 바뀌면 다른쪽도 바뀌게 된다.

따라서 clone 메소드가 원본 객체에 영향을 끼치지 않기 위해서는 참조 변수 내부 정보를 재귀적으로 복사해야 한다.

 

아이템7에 나왔던 Stack 예제를 살펴보자.

elements를 복제할 때 elements.clone을 호출하게 되는데, 이때 elements 필드가 final이라면 복제할 수 없다. (final에는 새로운 값 할당 불가)

package com.example.sypark9646.item13;

import java.util.Arrays;
import java.util.EmptyStackException;

public class StackExample implements Cloneable {

		private Object[] elements;
		private int size = 0;
		private static final int DEFAULT_INITIAL_CAPACITY = 16;

		public StackExample() {
				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;
				return result;
		}

		// 원소를 위한 공간을 적어도 하나 이상 확보한다.
		private void ensureCapacity() {
				if (elements.length == size) {
						elements = Arrays.copyOf(elements, 2 * size + 1);
				}
		}

		@Override
		public StackExample clone() {
				try {
						StackExample result = (StackExample) super.clone();
						result.elements = elements.clone(); // elements 배열의 clone을 재귀적으로 호출
						return result;
				} catch (CloneNotSupportedException e) {
						throw new AssertionError();
				}
		}
}

 

 

이 경우에는 element가 Object[]이기 때문에 제대로 복사가 됐다면, clone을 재귀적으로 호출하는 것만으로 충분하지 않은 경우도 있다.

package com.example.sypark9646.item13;

public class HashTableExample {

		private Entry[] buckets = new Entry[100];

		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());
//				}

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

		@Override
		public HashTableExample clone() {
				try {
						HashTableExample result = (HashTableExample) 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를 만들어 연결 리스트를 복사하게 되는데,

주의 할 점은 재귀적으로 들어가게 되면 연결 리스트의 크기가 클 경우 재귀 스택이 너무 많아져서 스택 오버플로우를 일으킬 수 있기 때문에

반복자로 deepCopy를 만들도록 하자

 

Object의 clone 메서드는 CloneNotSupportedExceptioin을 던지지만 재정의한 메서드는 throws 절을 없애는 편이 좋다.

검사 예외를 던지지 않아야 그 메서드를 사용하기 편하기 때문이다(아이템 71).

 

Parent-Child 클래스들에서 본 것과 같이, 상속해서 쓰기 위한 클래스 설계 방식 두 가지(아이템 19) 중 어느 쪽에서든, 상속용 클래스는 Cloneable을 구현해서는 안 된다.

 

대신 Object의 방식을 모방하여 제대로 작동하는 clone 메서드를 구현해 protected로 두고 CloneNotSupportedException도 던질 수 있다. 이 방식은 Object를 바로 상속할 때처럼 Cloneable 구현 여부를 하위 클래스에서 선택하도록 해준다.

 

다른 방법으로는, clone을 동작하지 않게 구현해놓고 하위 클 래스에서 재정의하지 못하게 할 수도 있다.

아래와 같이 clone을 쓰지 못하도록 Exception을 걸어두면 된다.

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

 

마지막으로 주의할 점은 Cloneable을 구현한 스레드 안전 클래스를 작성할 때는 clone 메서드 역시 적절히 동기화해줘야 한다.(아이템 78)

Object의 clone 메서드는 동기화를 신경 쓰지 않았다!! 

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

이를 위해서는 Cloneable을 구현하는 모든 클래스는 clone을 재정의해야 하며, 접근 제한자는 public으로, 반환 타입은 클래스 자신으로 변경한다.

이 메소드는 가장 먼저 super.clone을 호출한 후 필요한 필드를 전부 적절히 수정하게 된다. (객체의 deepCopy 실행)

 

 


 

휴... 넘 복잡하다. 그러니까 Cloneable을 이미 구현한 클래스를 확장하는 경우 외에는 복사 생성자와 복사 팩터리라는 더 나은 객체 복사 방식을 제공하도록 하는 것이 좋겠다.

  • 복사 생성자: 단순히 자신과 같은 클래스의 인스턴스를 인수로 받는 생성자
  • 복사 팩터리: 복사 생성자를 모방한 정적 팩터리 메서드
		public Parent(Parent parent) { // 복사 생성자
				this.parentField = parent.getParentField();
		}

		public static Parent newInstance(Parent parent) { // 복사 팩터리
				return new Parent(parent.getParentField());
		}

위와 같은 방식이 Cloneable/clone 방식보다 나은 이유는,

생성자를 쓰지 않는 방식이 아니기 때문에 엉성한 clone 규약에 기대지 않고, 정상적인 final 필드 용법과도 충돌하지 않으며, 불필요한 검사 예외를 던지지 않고, 형변환도 필요치 않다.

또 다른 장점은 해당 클래스가 구현한 `인터페이스` 타입의 인스턴스를 인수로 받을 수 있다. 이를 변환 생성자 (conversion constructor)와 변환 팩터리 (conversion factory) 라고 한다. 이들을 이용 하게 되면 클라이언트는 원본의 구현 타입에 얽매이지 않고 복제본의 타입을 직접 선택할 수 있다.

ex. HashSet 객체를 TreeSet 타입으로 복제할 수 있다.

 

 

 

용어

  • 공변 반환 타이핑 (covariant return typing): 자바 5에 추가된 개념으로, 메소드가 오버라이딩 될 때 더 좁은 타입으로 대체될 수 있다는 것이다.
  • 검사예외와 비검사예외
    • 복구 가능하다고 믿는다면 검사 예외 => 프로그래머가 명시적으로 예외 처리
    • 그렇지 않다면 런타임 예외를 사용한다

 

+ Recent posts