Study/이펙티브 자바 / / 2023. 9. 1. 23:52

[Effective Java 3E] clone 재정의는 주의해서 진행하라

💥 개요

Coneable은 복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스지만, 아쉽게 목적을 제대로 이루지 못했습니다. 가장 큰 문제는 clone 메서드가 Object에 선언되어있고 protected로 정의되어 있다는 점 입니다. 그래서 Cloneable을 구현하는 것 만으로 외부에서 clone 메서드를 호출할 수 없습니다.

Cloneable 인터페이스는 Object의 protected clone의 동작방식을 결정합니다.

Cloneable을 구현한 클래스에서 clone을 호출하면 그 객체를 복사한 객체를 반환하며, 그렇지 않은 클래스라면 CloneNotSupportedException을 던지게 됩니다.

Cloneable을 구현한 클래스의 clone은 public으로 제공하며, 사용자는 복제가 이뤄진다고 기대하지만 이 기대를 만족시키려면 그 클래스와 상위 클래스는 복잡하고, 강제할 수 없고, 허술한 프로토콜을 지켜야 하는데 그 결과로 깨지기 쉽고, 위험하고, 모순적인 생성자를 호출하지 않고 객체를 생성할 수 있는 메커니즘이 탄생합니다. 

 

🕹️허술한 clone 메서드의 일반규약

  • x.clone() != x
  • x.clone().getClass() == x.getClass() //필수 x
  • x.clone().equals(x) //필수 x지만 일반적으로 참

 

🩻 Cloneable의 문제점과 주의점

1.B클래스를 상속한 A 클래스의 clone을 호출 할 때, 상위클래스에서 정의한 clone이 호출 되면, A클래스가 아닌 B클래스가 반환되게 됩니다. 이를 처리하기 위해 공변 반환 타이핑(재정의한 메서드의 반환타입은 상위 클래스의 메서드가 반환하는 타입의 하위 타입일 수 있다.)을 이용할 수 있습니다.

2.clone 메서드를 가진 상위 클래스를 상속해 Cloneable을 구현할 때 모든 필드가 기본 타입이거나 불변 객체를 참조한다면 괜찮지만, 가변 객체를 참조한다면 가변 객체의 상태를 복사하여 원본 객체에 영향을 주지 않도록 보장해야 합니다. (Deep Copy)

3.가변 객체 필드가 배열이라면 clone은 런타임 타입과 컴파일타임 타입 모두가 원본 배열과 똑같은 배열을 반환한다. 따라서 배열을 복제할 때는 clone을 사용해야 합니다.(권장하고, 유일한 clone의 기능을 제대로 사용하는 예시)

4.public인 clone()에서는 throws 절을 없애야 사용하기 쉽습니다.

5.Cloneable을 구현한 스레드 안전 클래스를 작성할 때는 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()이 단순히 super.clone()을 반환한다면 위와 같이 elements 배열의 clone을 재귀적으로 호출합니다. 만약, 복제본 객체가 원본 객체의 필드를 참조하면 둘 중 하나를 수정하게 되면 다른 하나도 수정되어 불변식을 해치고 이상하게 동작하며 NPE가 발생할 수도 있습니다.

clone 메서드는 생성자와 같은 효과를 내기 때문에 clone은 원본 객체에 아무런 영향을 끼치지 않는 동시에 복제된 객체의 불변식을 복사해야 합니다.

하지만 이 방법의 단점으로, 참조 필드가 final이면 이 방법을 사용할 수 없고, Cloneable 아키텍처는 '가변 객체를 참조하는 필드는 final로 선언하라'는 일반 용법과 충돌하게되어 결국 final을 제거해야 하는 단점이 있습니다.

 

deepCopy()를 활용한 방법

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

첫번째 방법으로는 충분하지 않을수도 있습니다. 만약 배열안에 가변참조 객체가 있다면 문제가 발생하는데 예를 들어 해시테이블 내부는 버킷들의 배열이고, 각 버킷은 key-value의 쌍을 담는 연결리스트의 첫 엔트리를 참조하는데 이럴 때 배열을 순회하며 각각 깊은복사(Deep Copy)를 수행합니다.

하지만 이 방법도 재귀호출로 인해 연결리스트 원소수만큼 스택 프레임을 소비하고, 리스트가 길면 스택 오버플로를 일으킬 위험이 있습니다. 

 

수정된 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;
}

deepCopy를 활용한 방법의 문제를 어느정도 해소한 반복자를 통한 순회할 수 있습니다.

 

고수준 api를 활용하는 방법

super.clone()을 호출해 얻은 객체의 모든 필드를 초기 상태로 설정하고, 원본 객체의 상태를 다시 생성하는 고수준 메서드를 호출합니다. 이전에  HashTable이라면, bukets 필드를 새로운 버킷 배열로 초기화하고, 원본 테이블에 담긴 모든 key-value 쌍 각각에 복제 테이블에 put 메서드를 호출하면 됩니다.

간단하고 제법 우아한 코드가 만들어지지만 속도가 느린 단점이 있습니다. 또한 Cloneable 아키텍처와 어울리지 않는 방식입니다.

 

요약

  • Cloneable을 구현한 모든 클래스는 clone()을 재정의해야 합니다.
  • 접근 제한자는 public, 반환 타입은 클래스 자신으로 변경해야 합니다.
  • 가장 먼저 super.clone을 호출하고 필요한 필드를 적절히 수정해야 합니다.(가변객체)
  • 일련번호나 고유 ID는 기본 타입이나 불변이라도 수정해야 합니다.

 

🎆 복사 생성자와 복사 팩터리를 고려하라

//복사 생성자
public Stack(Stack s) {
    this.elements = s.elements.clone();
    this.size = s.size;
}

//복사 팩터리
public static Stack newInstance(Stack s) {
    return new Stack(s.elements, s.size);
}

웬만하면 복사생성자(자기 자신과 같은 클래스의 인스턴스를 인수로 받는 생서자)와 복사 팩터리를 통해서 언어 모순적이고 위험천만한 객체 생성 메커니즘(생성자 사용x)을 사용하지 않으며, 엉성한 규약에 기대지 않고, 정상적 final 필드 용법과도 충돌하지 않으며, 불필요한 검사 예외를 던지지않고, 형변환도 필요하지 않습니다.

추가로 인터페이스 타입의 인스턴스를 인수로 받을 수 있습니다. 이것들을 활용한 인터페이스 기반 복사 생성자와 복사 팩터리를 '변환 생성자(conversion constructor)'와 '변환 팩터리(conversion factory)'라고 합니다.

이것들을 사용해 클라이언트는 원본의 구현 타입에 얽매이지 않고 복제본의 타입을 직접 선택할 수 있습니다. (ex. HashSet 객체 s를 TreeSet으로 복제) clone으로는 불가능한 이 기능을 변환 생성자로는 간단히 처리할 수 있습니다. 

new TreeSet<>(s) //와 같은 형태로 간단하게 가능하다.
배열을 제외하고 나머지는 모두 복사 생성자와 복사 팩터리가 더 적합하다.

 

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

 

 

  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유