본문 바로가기

독서찰기(讀書札記)/이펙티브 자바

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

※ Cloneable 인터페이스에 대하여

더보기
  • 복제해도 되는 클래스임을 명시하는 용도의 mixin interface아이템20.
  • 메서드 하나 없는 Cloneable 인터페이스는 Object의 protected 메서드인 clone의 동작 방식을 결정한다.
    • 가장 큰 문제는 clone 메서드가 선언된 곳이 Cloneable이 아닌 Object라는 점 protected로 선언되어있다는 점이다.
    • Cloneable은 상위 클래스에 정의된 protected 메서드의 동작 방식을 변경한 것이다. (따라하지 말자!)
    • 그래서 Cloneable을 구현하는 것만으로는 외부 객체에서 clone 메서드를 호출할 수 없다.
  • Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환한다.
    • Cloneable을 구현하지 않은 클래스에서 호출하면 CloneNotSupportException을 던진다.
  •  

※ 요약

더보기

그냥 쓰지 마라. 복제 기능은 '생성자'와 '팩터리'를 이용하는 게 최고다. 맨 아래 내용 참고!
단, 배열만은 clone 메서드 방식이 가장 깔끔하다.

그래도 쓰겠다면,

  • Cloneable을 구현하는 모든 클래스는 clone을 재정의해야 한다.
  • 이때 접근 제한자는 public으로, 반환 타입은 클래스 자신으로 변경한다.
  • 이 메서드는 가장 먼저 super.clone을 호출한 후, 반환된 객체의 내부 '깊은 구조'에 숨어 있는 모든 가변 객체를 복사하고,
    복제본이 가진 객체 참조 모두가 복사된 객체를 가리키게 한다.
    이러한 내부 복사는 주로 clone을 재귀적으로 호출해 구현하지만, 이 방식이 항상 최선인 것은 아니다.
  • 기본 타입 필드와 불변 객체 참조만 갖는 클래스라면 아무 필드도 수정할 필요가 없다.
    단, 일련번호나 고유 ID는 비록 기본 타입이나 불변일지라도 수정해줘야 한다.

 

[Why]

  • 여러 문제점에도 불구하고 Cloneable 방식은 널리 쓰이고 있어서 잘 알아두는 것이 좋다.
  • 실무에서 Cloneable을 구현한 클래스는 clone 메서드를 public으로 제공하며 사용자는 당연히 복제가 제대로 이뤄지리라 기대한다.
    • 이 기대를 만족시키려면 그 클래스와 모든 상위 클래스는, 복잡하고+강제할 수 없고+허술하게 기술된 프로토콜을 지켜야만 하는데 그 결과로 깨지기 쉽고+위험하고+모순적인 메커니즘이 탄생한다.
  • clone 메서드의 일반 규약은 허술하다.
    • 관례상, 이 메서드가 반환하는 객체는 super.clone을 호출해 얻어야 한다.
    • 다음의 식들은 일반적으로 참이지만, 필수는 아니다.
    • x.clone() != x
      
      x.clone().getClass() == x.getClass()
      
      x.clone().equals(x)
      • 즉, clone 메서드가 super.clone이 아닌, 생성자를 호출해 얻은 인스턴스를 반환해도 컴파일러는 불평하지 않을 것이다.
      • e.g.) 클래스 B가 클래스 A를 상속할 때, 하위 클래스인 B의 clone은 B 타입 객체를 반환해야 한다. 그런데 A의 clone이 자신의 생성자, 즉 new A(...)로 생성한 객체를 반환한다면 B의 clone도 A 타입 객체를 반환할 수밖에 없다. 달리 말해 super.clone을 연쇄적으로 호출하도록 구현해두면 clone이 처음 호출된 상위 클래스의 객체가 만들어진다.

 

[When]

  • 제대로 동작하는 clone 메서드를 가진 상위 클래스를 상속해 Cloneable을 구현하고 싶을 때.

 

[How]

모든 필드가 기본 타입이거나 불변 객체를 참조하는 경우 (간단함)

  • public final class PhoneNumber implements Cloneable {
        private final short areaCode, prefix, lineNum;
        
        @Override
        public PhoneNumber clone() {
            try {
                return (PhoneNumber) super.clone();
            } catch (CloneNotSupportedException e) {
                throw new AssertionError();  // 일어날 수 없는 일이다.
            }
            
        ... // 나버지 코드는 생략
    }
  • Object의 clone 메서드는 Object를 반환하지만 PhoneNumber의 clone 메서드는 PhoneNumber를 반환하게 했다.
    • 자바가 공변(共變) 반환 타이핑(covariant return typing)을 지원하니 이렇게 하는 것이 가능하고 권장하는 방식이기도 하다.
    • 즉, 재정의한 메서드의 반환 타입은 상위 클래스의 메서드가 반환하는 타입의 하위 타입일 수 있다(=공변 반환 타이핑).
  • super.clone 호출을 try-catch 블록으로 감싼 이유는 Object의 clone 메서드가 검사 예외(checked exception)인 CloneNotSupportedException을 던지도록 선언되었기 때문이다.
    • PhoneNumber가 Cloneable을 구현하니, 우리는 super.clone이 성공할 것임을 안다.
    • CloneNotSupportedException이 사실은 비검사 예외(unchecked exception)였어야 했다는 신호다.아이템71

 

클래스가 가변 객체를 참조하는 경우 (복잡함)

문제 1

public class Stack {
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;
    
    @Override
    public Stack clone() {
        try {
            return (Stack)super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();  // 일어날 수 없는 일이다.
        }
    
    ... // 나머지 코드는 생략
}
  • 반환된 Stack 인스턴스의 elements 필드는 원본 Stack 인스턴스와 똑같은 배열을 참조할 것이다.
    • 원본이나 복제본 중 하나를 수정하면 다른 하나도 수정되어 불변식을 해친다는 이야기다.
    • clone은 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야 한다.

방법 1

  • Stack 클래스의 하나뿐인 생성자를 호출한다면 이러한 상황은 절대 일어나지 않는다. (clone 메서드는 사실상 생성자와 같은 효과)

방법 2

  • clone 메서드가 제대로 동작하려면 스택 내부 정보를 복사해야 하는데, 가장 쉬운 방법은 elements 배열의 clone을 재귀적으로 호출해주는 것이다.
@Override public Stack clone() {
    try {
        Stack result = (Stack)super.clone();
        result.elements = elements.clone();  // Object[] 형변환할 필요X
        return result;
    } catch (CloneNotSupportedException e) {
        throw new AssertionError();
    }
}
  • 배열의 clone은 런타임 타입과 컴파일타임 타입 모두가 원본 배열과 똑같은 배열을 반환한다.
  • 따라서 배열을 복제할 때는 배열의 clone 메서드를 사용하라고 권장한다. (clone 기능을 제대로 사용하는 유일한 예)
  • 하지만, elements 필드가 final이었다면 이 방식은 작동하지 않는다.
    • Cloneable 아키텍처는 '가변 객체를 참조하는 필드는 final로 선언하라'는 일반 용법과 충돌한다.
      (단, 원본과 복제된 객체가 그 가변 객체를 공유해도 안전하다면 괜찮다)
    • 또한, clone을 재귀적으로 호출하는 것만으로는 충분하지 않을 때도 있다. (e.g. 해시테이블 용 clone 메서드)

 

문제 2

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();
        }
    }
    
    ... // 나머지 코드는 생략
}
  • 복제본은 자신만의 버킷 배열을 갖지만, 이 배열은 원본과 같은 연결 리스트를 참조한다.
    • 이를 해결하려면 각 버킷을 구성하는 연결 리스트를 복사해야 한다.

방법 3

public class HashTable implements Cloneable {
    private Entry[] buckets = ...;
    
    private static class Entry {
        ... // 생략
        

        // 이 엔트리가 가리키는 연결 리스트를 재귀적으로 복사
        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 메서드는 자신이 가리키는 연결 리스트 전체를 복사하기 위해 자신을 재귀적으로 호출한다.
    • 재귀 호출 때문에 리스트의 원소 수만큼 스택 프레임을 소비하여, 리스트가 길면 스택오버플로를 일으킬 위험이 있다.
    • 이 문제를 피하려면 deepCopy를 재귀 호출 대신 반복자를 써서 순회하는 방향으로 수정해야 한다.

방법 4

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

방법 5

  1. super.clone을 호출하여 얻은 객체의 모든 필드를 초기 상태로 설정한다.
    • HashTable에서의 buckets 필드를 새로운 버킷 배열로 초기화한다.
  2. 원본 객체의 상태를 다시 생성하는 고수준 메서드들을 호출한다.
    • 원본의 모든 키-값 쌍 각각에 대해 복제본의 put(key, value) 메서드를 호출해 둘의 내용이 똑같게 해준다.
  • 아무래도 저수준에서 바로 처리할 때보다 느리다.
  • Cloneable 아키텍처의 기초가 되는 필드 단위 객체 복사를 우회하기 때문에 전체 Cloneable 아키텍처와는 어울리지 않는다.

 

주의사항 1

  • 생성자에서는 상속으로 인해 재정의될 수 있는 메서드를 호출하면 안 되는데아이템19, clone 메서드도 마찬가지다.
  • 만약 clone이 하위 클래스에서 재정의한 메서드를 호출하면, 하위 클래스는 복제 과정에서 자신의 상태를 교정할 기회를 잃게 되어
    원본과 복제본의 상태가 달라질 가능성이 크다.
    • 방법 5에서 얘기한 put(key, value) 메서드는 final이거나 private이어야 한다.
      (private이라면 final이 아닌 public 메서드가 사용하는 도우미 메서드일 것이다)

주의사항 2

  • Object의 clone 메서드는 CloneNotSupportedException을 던진다고 선언했지만 재정의한 메서드는 그렇지 않다.
  • public인 clone 메서드에서는 throws 절을 없애야 한다. 검사 예외를 던지지 않아야 그 메서드를 사용하기 편하기 때문이다아이템71

주의사항 3

  • 상속해서 쓰기 위한 클래스 설계 방식 두 가지아이템19 중 어느 쪽에서든, 상속용 클래스는 Cloneable을 구현해서는 안 된다.
    • 구현법1) Object 방식 모방하기
      • 제대로 작동하는 clone 메서드를 구현해 protected로 두고 CloneNotSupportedException도 던질 수 있다고 선언
        마치 Object를 바로 상속할 때처럼 Cloneable 구현 여부를 하위 클래스에서 선택하도록 해줌
    • 구현법2) clone을 동작하지 않게 구현해놓고 하위 클래스에서 재정의하지 못하게 하기
      @Override
      protected final Object clone() throws CloneNotSupportedException {
          throw new CloneNotSupportedException();
      }

주의사항 4

  • Cloneable을 구현한 thread safe 클래스를 작성할 때는 clone 메서드 역시 적절히 동기화해줘야 한다아이템78.

 

Cloneable / clone의 대안

// 복사 생성자
public Yum(Yum yum) { ... };

// 복사 팩터리
public static Yum newInstance(Yum yum) { ... };
  • 복사 팩터리는 복사 생성자를 모방한 정적 팩터리다.
  • 장점
    • 언어 모순적이고 위험천만하게 객체 생성 메커니즘(생성자를 쓰지 않는 방식)을 사용하지 않으며, 엉성하게 문서화된 규약에 기대지 않고, 정상적인 final 필드 용법과도 충돌하지 않으며, 불필요한 검사 예외를 던지지 않고, 형변환도 필요치 않다.
    • 또한 복사 생성자와 복사 팩터리는 해당 클래스가 구현한 '인터페이스' 타입의 인스턴스를 인수로 받을 수 있다.
      • 인터페이스 기반 복사 생성자/팩터리 : '변환 생성자(conversion constructor)', '변환 팩터리(conversion factory)'
      • 클라이언트는 원본의 구현 타입에 얽매이지 않고 복제본의 타입을 직접 선택할 수 있다.
        HashSet 객체 s를 TreeSet 타입으로 복제할 수 있다. new TreeSet<>(s)