개발 공부 기록하기/01. JAVA & Kotlin

짧은 코멘트와 함께하는 이펙티브 자바) #13 clone 재정의는 주의해서 진행하라

lannstark 2020. 10. 5. 11:53

짧은 코멘트

  1. Cloneable을 실제로 구현해본 적은 없는 듯 하다. 백엔드 개발자로써 특정 객체에 복제를 해야할 일이 드물기 때문이다.
  2. 얕은 복사(shallow copy)와 깊은 복사(deep copy) 차이는 알아두는 것이 좋다. 얕은 복사는 객체 내부에 있는 참조 객체가 복제되지 않는 것이고, 깊은 복사는 객체 내부에 있는 참조 객체까지 복제되는 것이다.

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

Cloneable은 복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스이다.

믹스인 : 클래스가 구현할 수 있는 타입. 믹스인이라 부르는 이유는 실제 클래스가 가지고 있는 주된 기능에 특정 타입을 구현함으로써 선택적인 기능을 혼합하기 때문이다

예시)

public interface Comparable<T> {
  public int compareTo(T o);
}

public class Point implements Comparable<Point>{
  private int x;
  private int y;

  @Override
  public int compareTo(Point o) {
    // ...
  }
}

Point의 믹스인 인터페이스에는 Comparable이 있다

Cloneable

Cloneable 인터페이스는 Object의 clone 메소드 동작 방식을 결정한다. 실무에서 Coneable을 구현한 클래스는 clone 메소드를 public으로 제공하며, 사용자는 당연히 복제가 제대로 이루어질 것이라 기대한다.

Object의 clone 메소드는 기본적으로 얕은 복사이다

  • final 클래스가 아니라면 super.clone()을 호출해야만 한다 > new 연산자를 통해 새로운 객체를 만든 다면 그 당시에는 문제가 없을 수 있지만, 그 클래스를 상속받은 클래스에서 clone 메소드를 구현하며 super.clone()을 호출했다면 하위 클래스 타입이 반환되는 것이 아니라 상위 클래스 타입이 반환되게 된다

클래스에 정의된 모든 필드가 기본 타입이거나 불변 객체를 참조한다면 다음과 같이 clone 메소드를 작성할 수 있다.

@Override
public PhoneNumber clone() {
  try {
    return (PhoneNumber) super.clone();
  } catch (CloneNotSupportedException e) {
    // CloneNotSupportedException : clone 메소드가 호출되었는데
    // 클래스가 Cloneable 인터페이스를 구현하지 않았을 때 던지는 예외
    throw new AssertionError(); // 일어날 수 없는 일
  }
}

가변 객체를 참조한다면 이제 꽤 복잡해지는데…

방법 1. 재귀적인 clone 호출

이럴 때는 clone을 재귀적으로 호출해야 한다. 예를 들어 간단한 Stack 클래스가 있다고 해보자

@Override
public Stack clone() {
  try {
    // super.clone()만 호출한다면
    // 복사는 되지만 가변 객체인 Elements가 같은 ref를 갖는 얕은 복사가 된다
    Stack result = (Stack) super.clone();

    // elements.clone 대신 배열의 clone을 사용할 수 있다
    result.elements = elements.clone();
    return result;
  } catch (CloneNotSupportedException e) {
    throw new AssertionError();
  }
}

만약 elements가 final이었다면 위의 방식을 사용할 수 없다. 이는 근본적인 문제로, Cloneable 아키텍처는 ’가변 객체를 참조하는 필드는 final로 선언하라’라는 일반 용법과 충돌하게 된다.

clone을 재귀적으로 사용하는 것만으로 충분하지 않을 때도 있다.

방법 2. 재귀 호출

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

  private static class Entiry {
    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();
    }
  }
}

방법 3. deepCopy를 할 때 반복적으로 복사하기

Entry deepCopy() {
  Entry result = new Entiry(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;
}

방법 4.

원본 객체를 다시 생성하는 고수준 메소드를 활용할 수도 있다. > HashTable을 예로 든다면, buckets 필드를 새로운 버킷 배열로 초기화 한다음 원본 테이블에 담긴 모든 키-값 쌍 각각에 대해 복제본 테이블의 put 메소드를 호출하는 것이다

주의 사항

  • 상속용 클래스는 Cloneable를 구현해서는 안 된다
  • Cloneable를 구현한 Thread-safe 클래스를 작성할 때는 clone 메소드 역시 적절히 동기화 해주어야 한다.

Cloneable을 구현하는 모든 클래스는 clone을 재정의 해야 한다. 이때 접근 제한자는 public으로, 반환 타입은 클래스 자신으로 변경한다. 이 메소드는 가장 먼저 super.clone을 호출한 후 필요한 필드를 전부 적절히 수정한다. 여기서 적절히란 말은, 복사된 클래스가 가지고 있는 가변 객체가 사실은 원본 클래스가 가지고 있는 가변객체이면 안된다는 뜻이다 이런 내부 복사는 주로 clone을 재귀적으로 호출해 구현하지만, 이 방식이 항상 최선은 아니다. 기본 타입 필드와 불변 객체 참조만 갖는 클래스라면 아무 필드도 수정할 필요가 없다. 단, 일련번호나 고유 ID는 비록 기본 타입이나 불변일지라도 수정해주어야 한다.

복사 생성자와 복사 팩토리

cloneable을 구현하는 대신 복사 생성자와 복사 팩토리를 이용할 수도 있다

복사 생성자 : 단순히 자신과 같은 클래스의 인스턴스를 인수로 받는 생성자

public Yum(Yum yum) { ... };
public static Yum newInstance(Yum yum) { ... };

복사 생성자와 복사 팩토리는 해당 클래스가 구현한 인터페이스 타입의 인스턴스를 인수로 받을 수 있다. HashSet 객체를 TreeSet으로 복제할 수 있다는 뜻이다