들어가기 전
이 글은 인프런 백기선님의 강의를 복습하며 작성한 글입니다.
이 글의 정리
- clone() 메서드를 구현할 때는 많은 고민할 부분이 존재한다.
- 따라서 인스턴스의 복사가 필요하다면 clone() 대신 생성자 / 팩토리 메서드를 이용하라.
핵심 정리 : 애매모호한 clone 규약
- clone 규약
- x.clone() != x 반드시 true (clone 된 녀석은 원본과는 다른 인스턴스여야 함.)
- x.clone().getClass() == x.getClass() 반드시 true (clone 된 녀석은 반드시 같은 클래스여야 함)
- x.clone().equals(x) == true (True / False 가능)
- 복사를 했다고 하더라도, 객체를 식별하는 식별자가 달라져야 하는 경우라고 한다면, clone 되어도 같은 객체가 아닐 수 있다.
- clone() 메서드 구현 방법
- 불변 객체라면 다음 순서대로 구현하면 됨.
- clone()을 구현하는 클래스는 Cloneable 인터페이스를 구현. (중요)
- 해당 클래스에서 clone 메서드를 재정의한다. 이 때 super.clone()을 사용해서 구현. (생성자 사용하면 안됨)
- 불변 객체라면 다음 순서대로 구현하면 됨.
Cloneable 인터페이스 자체는 아무런 메서드도 없는 인터페이스다. 하지만 clone() 메서드를 사용하고자 하는 클래스는 Cloneable 인터페이스를 구현해야만 한다. 만약 Cloneable 인터페이스를 구현하지 않은 클래스에서 clone()을 사용하면, clone() 메서드는 동작하지 않는다.
코드
clone()으로 만들어지는 인스턴스는 새로운 인스턴스다. clone() 메서드는 생성자를 이용해서 새로운 인스턴스를 만드는 것이 아니라 super.clone() 이용해서 새로운 인스턴스를 만든다. 아래 코드 불변 객체에서 일반적으로 clone()을 구현하는 방법이다.
// Cloneable 인터페이스 구현
public final class PhoneNumber implements Cloneable {
...
@Override
// 접근 지시자 수정 + 하위 타입 반환 (기본은 Object)
public PhoneNumber clone() {
try {
// 클래스 캐스팅 + super.clone()
return (PhoneNumber) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError(); // 일어날 수 없는 일이다.
}
}
clone() 메서드를 재정의 할 때 주의해서 볼 점은 다음과 같다.
- Cloneable 인터페이스를 구현해야 함.
- 만약 구현하지 않으면, AssertionsError가 발생한다. clone()을 사용하기 위해서는 반드시 Cloneable 인터페이스가 구현되어야 함.
- 접근 지시자를 public으로 변경 (Protected → public)
- clone()의 접근 지시자는 기본적으로 Protected다. 이렇게 구현하면 하위 클래스에서 clone()을 사용할 수 없기 때문에 public으로 변경해서 구현해야함. 그래야 하위 클래스에서도 사용할 수 있음.
- 반환 타입을 Object → PhoeNumber로 변경
- 자바에서는 오버라이딩 메서드의 리턴 타입을 하위 타입으로 선언해도 오버라이딩으로 인정한다.
- 하위 타입으로 반환한 메서드는 사용하는 쪽에서 타입 캐스팅을 하지 않아도 된다는 장점이 있다.
- clone()의 기본 반환 타입은 Object인데, 이것을 Object의 하위 타입인 PhoneNumeber로 수정함.
clone() 구현할 때, 왜 super.clone()을 사용해야 할까?
만약 clone()을 구현할 때, 생성자를 이용해서 구현하면 어떻게 될까? 결론부터 이야기 하면 하위 클래스에서 clone()을 호출하면 classCastException이 발생해서 clone()에서 실패하게 된다. 왜 이런 문제가 발생할까?
- Item 클래스가 자기 자신의 clone()을 호출하는 것은 문제 없음.
- Subitem 클래스의 clone() 메서드가 재정의 되지 않으면, Subitem은 기본적으로 super.clone()을 호출함.
- Item.clone()이 호출되면 Item이 반환된다. 이 때 Subitem에서는 (Subitem)으로 타입 캐스팅 후 반환하려 함.
이 과정에서 3번에서 ClassCastException이 발생하게 된다. 왜냐하면 상위 타입을 하위 타입으로 다운 캐스팅 할 수가 없기 때문이다. 이런 이유 때문에 공통적으로 clone()를 사용하기 위해서, clone()는 생성자를 사용하지 않고 super.clone()을 이용해서 구현해야 한다.
public class Item implements Cloneable {
private String name;
/**
* 이렇게 구현하면 하위 클래스의 clone()이 깨질 수 있다. p78
* @return
*/
@Override
public Item clone() {
Item item = new Item();
item.name = this.name;
return item;
}
}
public class SubItem extends Item implements Cloneable {
private String name;
@Override
public SubItem clone() {
return (SubItem)super.clone();
}
public static void main(String[] args) {
SubItem item = new SubItem();
SubItem clone = item.clone();
System.out.println(clone != item);
System.out.println(clone.getClass() == item.getClass());
System.out.println(clone.equals(item));
}
}
중간 정리
Item 클래스에서 정석적으로 선언된 clone()은 결과적으로 super.clone()을 계속 호출한다. clone() 메서드의 호출 결과는 어떤 클래스에서 clone()이 호출되느냐에 따라 달라지게 될 것이다. 예를 들어 Subitem에서 clone() 메서드가 호출되면, SubItem 클래스는 Item의 clone()을 super.clone()으로 호출하겠지만, 결과적으로는 Subitem 인스턴스가 복제되게 된다.
핵심 정리 : 가변 객체의 clone 구현하는 방법
- 가변 객체의 clone도 기본적인 구현 방법은 동일하다. 하지만 아래의 주의 사항까지 함께 고려해야 한다.
- 접근 제한자는 public, 반환 타입은 자신의 클래스.
- super.clone()을 호출해서 구현함.
- Cloneable 인터페이스를 구현해야 함.
- 주의해야 할 점.
- 배열을 복제할 때는 배열의 clone 메서드를 사용하라.
- 경우에 따라 final을 사용할 수 없을지도 모른다.
- 필요한 경우 deep copy를 해야함. (특히 배열 복제 시)
- super.clone()으로 객체를 만든 뒤, 고수준 메서드를 호출하는 방법도 있다.
- 오버라이딩 할 수 있는 메서드는 참조하지 않도록 조심해야 한다. (컨텍스트가 바뀌어 질 수 있음)
- 상속용 클래스는 Cloneable을 구현하지 않는 것이 좋음. (상속 시 고려해야 할 부분이 많아짐)
- Cloneable을 구현한 스레드 안전 클래스를 작성할 때는 동기화를 해야함.
가변 객체일 때, clone()을 구현하려면 불변 객체 대비 고려해야 할 점이 많다. 따라서 clone()을 사용하기 보다는 생성자나 팩토리 메서드를 사용하는 것을 추천한다. 그렇지만 왜 잘 안 쓰는지에 대해서도 잘 알아야 하기 때문에 주의 사항을 하나씩 살펴보고자 한다.
배열을 복제할 때는 배열의 clone()를 사용하라.
특정 클래스를 clone 할 때, 필드로 배열이 존재한다면 Arrays.clone()을 이용해야 한다. 특정 클래스에 대한 clone()을 실행하면 필드는 같은 참조 값을 가진다. 즉, 원본 / 복제본이 있고 필드로 List를 하나 가지고 있다면 원본 / 복제본은 동일한 List를 참조하고 있다. 예를 들면 아래 같은 코드다.
public class Stack implements Cloneable {
private Object[] elements;
private int size = 0;
@Override
public Stack clone() {
Stack result = (Stack) super.clone();
return result;
}
}
이 상태의 문제점은 무엇일까? 원본이 리스트에서 값을 빼거나 추가할 경우, 그 결과가 복제본의 리스트에도 동일한 영향을 준다는 것이다. 일반적으로 clone()은 복제본을 사용하고 싶어서 사용하는 것인데 어쩌다보니 같은 객체에 적용하고 있다. 예를 들어 아래 main()를 실행해보면 copy 인스턴스에서는 어떠한 요소도 남아있지 않을 것이다.
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() 메서드를 이용해서 복제된 인스턴스에 지정해주어야 한다. 이를 고려한 clone() 메서드는 다음과 같다.
@Override
public Stack clone() {
try {
Stack result = (Stack) super.clone();
// 배열은 따로 복제해서 필드에 지정해줘야 함. 그렇지 않으면 같은 필드를 바라봄.
result.elements = elements.clone();
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
배열 복제의 문제점 → 같은 인스턴스를 가짐 (배열의 clone은 Shallow Copy임)
그냥 clone()을 하면 복제된 인스턴스들이 같은 배열을 바라보고 있다는 문제점이 있어, 이것을 아래 코드로 해결했다. 그렇지만 아래 코드는 더 큰 문제를 내포하고 있다. 기존 배열이 가지고 있는 인스턴스, 복제된 배열이 가지고 있는 인스턴스가 동일하다는 문제다. 이것은 배열의 clone()이 shallow Copy를 하기 때문에 발생하는 문제다.
public class Stack implements Cloneable {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
@Override
public Stack clone() {
try {
Stack result = (Stack) super.clone();
result.elements = elements.clone();
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}
예를 들자면 원본이 elements1, 복제본이 elements2라고 한다면 아래 공식이 성공한다. '==' 객체의 주소가 같은지 확인하는 연산잔다. 따라서 이런 문제를 해결 하기 위해서는 배열들을 clone()할 때, Deep Copy를 하도록 코드를 변경해야한다.
elements1[0] == elements2[0]
배열 복사 → Deep Copy로 변경 (final 사용이 불가능함)
배열의 clone() 메서드는 Shallow Copy를 한다. 따라서 복제된 배열에 있는 요소들은 오리지널 배열의 요소들과 동일한 객체(주소적으로)를 가지고 있기 때문에 오리지널 배열의 요소 값의 변경이 복제 배열의 요소 값에도 영향을 미친다는 것이다. 이 문제를 해결하기 위해서 배열의 clone()을 사용하는 것이 아니라, 직접 Deep Copy로 바꿔준다.
@Override
public HashTable clone() {
HashTable result = null;
try {
result = (HashTable)super.clone();
result.buckets = new Entry[this.buckets.length];
for (int i = 0 ; i < this.buckets.length; i++) {
if (buckets[i] != null) {
// etnry 직접 생성해서 넣어주기
result.buckets[i] = this.buckets[i].deepCopy(); // p83, deep copy
}
}
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
public 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;
}
}
이제는 배열의 clone()이 아니라, 배열을 직접 만들고 Entry 객체를 만들어서 하나씩 배열에 넣어주는 형태가 된다. Deep Copy까지 완료하면 배열도 다르고, 배열 안의 객체들도 모두 달라진다. 따라서 위의 문제가 해결될 수 있다. 하지만 한 가지 단점이 생기는데, 일부 필드에 final을 쓸 수 없게 된다는 점이다.
위 코드에서는 super.clone()으로 HashTable 인스턴스를 생성하고, 그 인스턴스의 필드에 새로운 배열을 배정하는 형태로 코드가 구성된다. 만약 인스턴스의 필드를 final로 선언했다면, 이런 방식의 Deep Copy를 사용할 수 없게 된다.
clone() 안에서 다른 메서드 호출할 때 주의사항 → 오버라이딩 불가능하게 해야함.
clone() 안에서 다른 메서드를 호출하는 경우가 있을 수 있다. 예를 들면 아래와 같은 경우다.
@Override
public Object clone() {
Object result = super.clone();
createHello();
}
이 때, createHello() 같은 메서드들은 하위 클래스에서 재정의 할 수 없도록 반드시 final로 선언해야 한다. 그 이유는 하위 클래스에서 createHello()를 재정의하게 된다면, clone() 메서드가 호출될 때 원하지 않는 형태로 동작할 수 있기 때문이다. 객체를 만드는 과정에서 사용되는 보조 메서드들이 하위 클래스에서 재정의 가능하도록 만들면 안된다. (clone, 생성자) 그렇게 하면 다르게 동작할 수 있기 때문이다.
상속용 클래스는 Cloneable 인터페이스를 구현하지 말자.
상속용 클래스는 Cloneable 인터페이스를 구현하지 않는 것이 좋다. 만약 Cloneable 인터페이스를 구현한다면, 이 클래스를 이용해 하위 클래스를 만들려는 프로그래머에게 많은 짐을 떠안겨 주게 된다. Cloneable 인터페이스를 구현하는 것은 clone()을 사용하겠다는 것이다. 가변 클래스의 clone()을 구현할 때는 많은 고민 사항이 필요한데, Cloneable 인터페이스를 구현한 클래스의 서브 클래스를 만든다는 것은 clone()에 대한 많은 고민 사항이 생기게 된다. 즉, 새로운 클래스를 만드려는 사람에게 큰 고민을 넘겨주게 되는 것이다.
이런 경우를 막기 위해서 책에서는 두 가지 방안을 제시한다.
- abstract class에서 clone()을 직접 구현해주고, 하위 클래스가 굳이 구현을 하지 않아도 되게끔 만드는 방법.
- 하위 클래스에서 구현을 못하도록 막는 방법. (final)
super.clone()으로 객체를 만든 뒤, 고수준 메서드를 호출하는 방법도 있다.
HashTable에는 put, get 같은 것이 없다. put(), get() 같은 메서드들이 고수준 메서드다. 객체를 만드는 것만 super.clone()을 사용해서 만들고, 객체가 가지고 있는 모든 필드는 고수준 API를 사용해서 만드는 방법이다. 그런데 이 경우에는 위에서 이야기 했던 것처럼 하위 클래스에서 재정의 할 수 없도록 메서드를 막아야 한다.
Cloneable을 구현한 스레드 안전 클래스를 작성할 때는 동기화를 해야한다.
만약 Cloneable을 구현한 스레드 안전 클래스로 만들어져야 한다면, clone() 메서드의 선언 부분에 syncronzied 키워드를 봍여서 동기화 처리해야 한다.
핵심 정리 : clone의 대안 → 생성자를 사용해서 써라. (Copy 기능을 제공해야 한다면)
위에서 볼 수 있듯이 clone() 메서드는 구현할 때, 고민해야 할 부분이 너무 많고 불완전하다. 따라서 굳이 clone() 메서드를 구현해서 사용하는 것보다는 생성자 + 팩토리 메서드를 이용해서 처리하는 것이 좋다.
자바에서 제공하는 대표적인 예시는 TreeSet 같은 클래스다. TreeSet 생성자는 Collection 타입의 인스턴스를 매개변수로 받고, 생성자 블록 내부에서 addAll() 메서드를 이용해서 Deep Copy를 해준다. 즉, clone()이 아니라 생성자를 이용해서 Copy를 해주는 것이다.
public TreeSet(Collection<? extends E> c) {
this();
addAll(c);
}
앞서 공부했던 PhoneNumber 클래스에서 clone() 대신 생성자를 통해서 객체를 복사해 줄 수 있다. PhoneNumber는 이렇게 처리해 볼 수 있겠다.
// Clone 대신 생성자로 Copy 가능.
public PhoneNumber(PhoneNumber phoneNumber){
this(phoneNumber.areaCode, phoneNumber.prefix, phoneNumber.lineNum);
}
정리해보면 다음과 같다.
clone() 메서드를 통해 생성되는 인스턴스는 어떻게 만들어지는지 과정이 불분명하다. 그리고 일반적으로 객체를 생성할 때 항상 생성자 + 팩토리 메서드를 통해서 만들어진다고 생각하기 때문에 객체가 가져야 하는 전제 조건에 대한 Validation은 모두 생성자 + 팩토리 메서드에 있다. 따라서 clone()을 사용하면, 적절한 Validation 없이 객체가 만들어 질 수 있다는 점이다.
또한 clone()을 활용해서 deepCopy 같은 것을 하려면, 사용하는 필드 자체에 final을 선언할 수 없다. 따라서 불변 객체도 만들 수 없다는 단점이 있다. 이런 단점이 있기 때문에 clone()을 사용하는 것보다는 생성자 + 팩토리 메서드를 구현해서 복사된 인스턴스를 제공하는 것이 좋다.
'프로그래밍 언어 > JAVA' 카테고리의 다른 글
Effective Java : 아이템 34. int 상수 대신 열거 타입을 사용하라 (0) | 2023.07.31 |
---|---|
Effective Java : 아이템 35. ordinal() 대신 인스턴스 필드를 사용하라 (0) | 2023.07.31 |
Effective Java : 아이템 13. 완벽공략 (0) | 2023.05.16 |
Effective Java : 아이템 11. 완벽 공략 (0) | 2023.05.14 |
Effective Java : 아이템 11. equals를 재정의하면, hashCode도 재정의하라. (0) | 2023.05.14 |