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

Jimin Lim·2022년 3월 20일
0

Effective Java

목록 보기
13/38
post-thumbnail
post-custom-banner

아이템 13

clone 재정의는 주의해서 진행하라

clone 메서드가 선언된 곳은 Object이고 protected이므로 Cloneable을 구현하는 것만으로 외부 객체에서 clone메서드를 호출할 수 없다.

Cloneable: 복제해도 되는 클래스임을 명시하는 용도의 mixin interface
mixin: 본인의 기능 이외 추가로 구현할 수 있는 자료형

Cloneable 인터페이스의 역할

: Object의 protected메서드인 clone의 동작 방식 결정
Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환하며 그렇지 않은 클래스의 인스턴스에서 호출하면 CloneNotSupportedException을 던진다.

clone 메서드의 일반 규약

  1. x.clone() != x
    원본 객체와 복제 객체는 다른 객체이다.
  2. x.clone().getClass() == x.getClass()
    반드시 만족해야 하는 것은 아니다.
  3. x.clone().equals(x)
    필수는 아니다.
  4. x.clone().getClass() == x.getClass()
    반환된 객체와 원본 객체는 독립적이어야 한다.

clone 메서드의 역할

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

예를 들어, 아이템 7에서 소개한 Stack 클래스에서

public class Stack {
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

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

super.clone의 결과를 그대로 반환한다면, elements필드는 원본 stack 인스턴스와 같은 배열을 참조하게 된다. 따라서 하나를 수정하면 다른 하나도 수정되어 불변식을 해치게된다.

이를 해결하기 위한 가장 쉬운 방식은 elements 배열의 clone을 재귀적으로 호출해 주는 것이다.

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

하지만 아래와 같이 재귀적으로 호출하는 것만으로는 충분하지 않을 때도 있다.

public class HashTable implements Cloneable {
  private Entry[] buckets = ...;

  private static class Entry {
    final Object key;
    Object value;
    Entry next;

    Entry(Obejct 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();
    }
  }
public class HashTable implements Cloneable {
  private Entry[] buckets = ...;

  private static class Entry {
    final Object key;
    Object value;
    Entry next;

    Entry(Obejct 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]; //1. 적절한 크기의 새로운 버킷 배열 할당
      for (int i = 0; i < buckets.length; i++) { //2. 다음 원래의 버킷 배열을 순회하며 
        if (buckets[i] != null) { //비지 않은 각 버킷에 대해 깊은 복사
          result.buckets[i] = buckets[i].deepCopy();
        }
      }
      return result;
    } catch (CloneNotSupportedException e) {
      throw new AssertionError();
    }
  }
  ...
}

이 기법은 간단하지만 재귀 호출 때문에 리스트의 원소 수만큼 스택 프레임을 소비하고 리스트가 길다면 오버플로를 일으킬 위함이 있다. 이 문제를 피하기 위해선 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;
}

마지막 방법은 super.clone을 호출해 얻은 객체의 모든 필드를 초기 상태로 설정한 후, 원본 객체의 상태를 다시 생성하는 고수준 메서드를 호출해 둘의 내용을 똑같게 해주면 된다. 이 방식은 저수준에서 바로 처리할 때보다 느리며 필드 단위 객체 복사를 우회하기에 전체 Cloneable 아키텍처와는 어울리지 않는 방식이다.

주의 사항

  • 생성자에서 재정의될 수 있는 메서드를 호출하지 않아야 하는데 clone 메서드도 마찬가지다.
    만약 clone이 하위에서 재정의한 메서드를 호출하게 된다면 하위 클래스는 복제 과정에서 자신의 상태를 교정할 기회를 잃게 되어 원본과 복제본의 상태가 달라질 확률이 커진다. 따라서 원본 객체의 상태를 다시 생성하는 메서드는 final이거나 private이어야 한다.
  • 상속용 클래스는 Cloneable을 구현해서는 안된다.
  • Cloneable을 구현한 스레드 안전 클래스를 작성할 때는 clone 메서드 역시 적절히 동기화해줘야 한다.
  • Cloneable을 구현하는 모든 클래스는 clone을 재정의해야 한다.

생성자와 팩터리

복사 생성자

public Yum(Yum yum) {...};//본인과 같은 클래스의 인스턴스를 인수로 받는다.

복사 팩터리

public static Yum newInstance(yum yum){...};//복사 생성자를 모방한 정적 팩터리

복사 생성자, 복사 팩터리를 사용하면 불필요한 검사 예외를 던지지 않고, 형변환도 필요하지 않는다. 또한 구현한 인터페이스 타입의 인스턴스를 인수로 받을 수 있다. 따라서 원본의 구현 타입에 얽매이지 않고 복제본의 타입을 직접 선택할 수 있게 된다.

핵심 정리

복제 기능은 생성자, 팩터리를 이용하는 게 최고지만 배열만은 clone 메서드 방식이 가장 깔끔한, 이 규칙의 합당한 예외이다.

profile
💻 ☕️ 🏝 🍑 🍹 🏊‍♀️
post-custom-banner

0개의 댓글