본문 바로가기
개발 공부/Java

이펙티브 자바 아이템 13 - clone 재정의는 주의해서 진행하라 - 핵심 정리

by 개발인생 2022. 10. 27.
반응형

아이템 13 - clone 재정의는 주의해서 진행하라 - 핵심 정리

이 글은 백기선 님의 이펙티브 자바 강의와 이펙티브 자바 3 / E 편을 참고하여 작성하였습니다.

clone 규약

clone 메서드는 Object 에 정의되어있는 메서드이다.

public interface Cloneable {
}

아무것도 정의되어 있지 않은 Cloneable 인터페이스가 존재한다.

clone 메서드를 구현하려면 Cloneable 인터페이스를 implements 해야한다.

Cloneable 인터페이스를 implements 하지 않으면 clone 메서드 는 동작하지 않는다.

오브젝트에 정의되어있는 clone 메서드 기능을 제공하기 위해서는

  • Cloneable 인터페이스를 implements 한다.
  • clone 메서드 를 오버라이딩한다. (이때 super.clone() 메서드를 사용해야 한다.)

clone 메서드 를 재정의 하기위해서는 몇가지 규약을 지켜야한다.

x.clone() != x // 반드시 true

클론을 한 객체는 반드시 원본과는 다른 객체여야한다.

주소연산자로 비교했을 때 다른 객체라는 결과가 나와야한다.

즉, 레퍼런스 자체가 다른 오브젝트여야 한다.

x.clone().getClass() == x.getClass() // 반드시 true

클론을 했던 클래스와 원본 클래스는 동일한 클래스 여야한다.

getClass() 로 비교했을 때 같다는 결과가 나와야한다.

x.clone().equals(x) // true 가 아닐수도 있다.

equals 로 비교했을 때 같을수도 있고, 다를 수도 있다.

복사를 했다 하더라도 그 객체를 식별하는 유일한 값들이 달라져야하는 경우가 있다.

복사를 하는 과정 중에 clone() 메서드 내부 에서 달라져야하는 필드값은 새로 설정해야하는 경우가 있다.

때문에 반드시 equals 는 true 가 아니어도 된다.

public final class PhoneNumber implements Cloneable {
   private final short areaCode, prefix, lineNum;

   public PhoneNumber(int areaCode, int prefix, int lineNum) {
      this.areaCode = rangeCheck(areaCode, 999, "지역코드");
      this.prefix   = rangeCheck(prefix,   999, "프리픽스");
      this.lineNum  = rangeCheck(lineNum, 9999, "가입자 번호");
      System.out.println("constructor is called");
   }

   private static short rangeCheck(int val, int max, String arg) {
      if (val < 0 || val > max)
         throw new IllegalArgumentException(arg + ": " + val);
      return (short) val;
   }

   // 코드 13-1 가변 상태를 참조하지 않는 클래스용 clone 메서드 (79쪽)
   @Override
   public PhoneNumber clone() {
      try {
         return (PhoneNumber) super.clone();
      } catch (CloneNotSupportedException e) {
         throw new AssertionError();  // 일어날 수 없는 일이다.
      }
   }

   @Override public boolean equals(Object o) {
      if (o == this)
         return true;
      if (!(o instanceof PhoneNumber))
         return false;
      PhoneNumber pn = (PhoneNumber)o;
      return pn.lineNum == lineNum && pn.prefix == prefix
            && pn.areaCode == areaCode;
   }

   @Override public int hashCode() {
      int result = Short.hashCode(areaCode);
      result = 31 * result + Short.hashCode(prefix);
      result = 31 * result + Short.hashCode(lineNum);
      return result;
   }

   @Override public String toString() {
      return String.format("%03d-%03d-%04d",
            areaCode, prefix, lineNum);
   }
}

clone 메서드를 재정의한 PhoneNumber 클래스를 작성한다.

public class main {
   public static void main(String[] args) {
      PhoneNumber pn = new PhoneNumber(707, 867, 5309);
      Map<PhoneNumber, String> m = new HashMap<>();
      m.put(pn, "제니");
      PhoneNumber clone = pn.clone();
      System.out.println(m.get(clone));

      System.out.println(clone != pn); // 반드시 true
      System.out.println(clone.getClass() == pn.getClass()); // 반드시 true
      System.out.println(clone.equals(pn)); // true가 아닐 수도 있다.
   }
}

clone 규약을 어떻게 지키고 있는지 확인하는 코드를 작성한다.

clone 만들어지는 인스턴스는 생성자를 사용하지 않는다.

   @Override
   public PhoneNumber clone() {
      try {
         return (PhoneNumber) super.clone();
      } catch (CloneNotSupportedException e) {
         throw new AssertionError();  // 일어날 수 없는 일이다.
      }
   }

clone 메서드를 재정의 했을 시 최종적으로는 super.clone() 을 호출하게 된다.

결국 Object에 있는 clone 메서드를 사용한다.

대표적인 불변 객체 에서의 clone 메서드를 구현하는 방법이다.

@Override
protected Object clone() throws CloneNotSupportedException {
      return super.clone();
      }

clone() 메서드의 원래 형태이다.

앞서 정의한 clone() 메서드와는 차이가 있다.

   @Override
   public PhoneNumber clone() {
      try {
         return (PhoneNumber) super.clone();
      } catch (CloneNotSupportedException e) {
         throw new AssertionError();  // 일어날 수 없는 일이다.
      }
   }

하나하나 살펴보자면 protected 대신 public 을 사용했다.

어떤 클래스를 상속 받아서 오버라이딩 을 할 때 접근지시자 는 상위 클래스에 있는 접근지시자와 같거나 넓어야한다.

만약 protected 를 사용한다면 해당 클래스의 하위 클래스에서만 사용할 수 있게 된다.

보통 clone() 메서드를 사용하는 클라이언트 코드는 해당 클래스 외부에 있는 클래스일 것이다.

자바는 오버라이딩하는 메서드 에서 리턴 타입이 오버라이딩을 해야하는 메서드의 리턴타입의 하위 타입 을 선언해도 오버라이딩으로 인정 해준다.

때문에 원래 리턴타입인 Object 보다 더 구체적인 타입으로 리턴 타입을 선언할 수 있다.

구체적인 타입으로 리턴하면 clone() 를 사용하는 쪽에서 타입 캐스팅을 해주지 않아도 된다.

CloneNotSupportedExceptionChecked Exception 계열이다.

CloneNotSupportedException 이 발생했을 때 딱히 해줄 일이 없기 때문에 다른 오류를 던지도록 작성한다.

그렇게 해서

   @Override
   public PhoneNumber clone() {
      try {
         return (PhoneNumber) super.clone();
      } catch (CloneNotSupportedException e) {
         throw new AssertionError();  // 일어날 수 없는 일이다.
      }
   }

위와 같은 코드를 작성하게 된다.

주의할 점은 super.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;
   }
}

super.clone() 을 사용하지 않고 생성자를 사용하는 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 클래스를 상속받은 SubItem 클래스를 생성한다.

위의 코드에서는 clone() 를 호출하면서 에러가 발생한다.

구체적인 타입은 상위타입으로 변환이 되지만 상위 타입은 구체적인 타입으로 변환이 안되기 떄문이다.

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 = (SubItem) item.clone();

      System.out.println(clone != item);
      System.out.println(clone.getClass() == item.getClass());
      System.out.println(clone.equals(item));
   }
}

SubItem 클래스에서 clone 메서드를 재정의 하지 않았다.

하지만 역시나 casting 에러가 발생한다.

Object 에 암묵적으로 구현되어 있는 clone 메서드가 사용되면서 결국 super.clone() 이 호출되면서

결국 Item 클래스의 clone 메서드가 호출된다.

   @Override
   public Item clone() {
      Item item = new Item();
      item.name = this.name;
      return item;
   }

이렇게 생성자를 사용하면 안된다.

super.clone() 을 통해서 나오는 인스턴스가 비 결정적 이기 때문이다.

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

위처럼 clone 메서드를 작성한다.

super.clone() 호출 시 나오게 되는 인스턴스의 타입은 하위 타입에서 호출 이 되면 하위 타입 으로 나오게 된다.

super.clone() 이 어디서 호출이 되느냐에 따라 실제 반환되는 인스턴스 타입이 달라지게 된다.

때문에 clone 메서드 구현시 생성자 대신 반드시 super.clone() 을 호출해야 한다.

불변 객체 는 다음의 사항들을 지켜주면 clone 메서드 사용이 가능하다.

  • Cloneable 인터페이스를 구현
  • clone 메서드를 재정의한다. 이때 super.clone()을 사용해야 한다.

가변 객체에서의 clone

기본적으로 가변 객체에서도 clone 메서드를 정의하는 규약은 같다.

  • Cloneable 인터페이스를 구현
  • clone 메서드를 재정의한다. 이때 super.clone()을 사용해야 한다.

여기에 접근 제한자는 public, 반환 타입은 자신의 클래스 로 작성하는 것도 같다.

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

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

   public void push(Object e) {
      ensureCapacity();
      elements[size++] = e;
   }

   public Object pop() {
      if (size == 0)
         throw new EmptyStackException();
      Object result = elements[--size];
      elements[size] = null; // 다 쓴 참조 해제
      return result;
   }

   public boolean isEmpty() {
      return size ==0;
   }

   // 코드 13-2 가변 상태를 참조하는 클래스용 clone 메서드
   // TODO stack, copy -> elements
   @Override public Stack clone() {
      try {
         Stack result = (Stack) super.clone();
         return result;
      } catch (CloneNotSupportedException e) {
         throw new AssertionError();
      }
   }

   // 원소를 위한 공간을 적어도 하나 이상 확보한다.
   private void ensureCapacity() {
      if (elements.length == size)
         elements = Arrays.copyOf(elements, 2 * size + 1);
   }
}

Stack 클래스를 작성한다.

위의 코드처럼 clone 메서드를 하게된다면 원본과 복제본이 같은 elements 배열참조 하게 된다.

public class main {
   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]);
   }
}

위 코드를 실행시켜보면 복제한 객체와 원복 객체가 같은 배열을 참조하고 있다는 걸 확인할 수 있다.

동일한 배열을 보고있기 때문에 원본 객체에서 작업한 내용이 복제한 객체에도 영향을 미친다.

   // 코드 13-2 가변 상태를 참조하는 클래스용 clone 메서드
    // TODO stack -> elementsS[0, 1]
    // TODO copy -> elementsC[0, 1]
    // TODO elementsS[0] == elementsC[0]
   @Override public Stack clone() {
      try {
         Stack result = (Stack) super.clone();
         result.elements = elements.clone(); // 배열 복사
         return result;
      } catch (CloneNotSupportedException e) {
         throw new AssertionError();
      }
   }

배열을 복사해서 원본과 복사한 객체가 제 각각의 배열을 참조하도록 해야한다.

주의할 점은 배열만 다를 뿐이지 배열 안의 요소는 같은 인스턴스를 참조한다.

deep copy 가 아닌 shallow copy 이기 때문이다.

배열만 새로 만들뿐 배열 안에 들어있는 인스턴스는 copy 하지 않는다.

배열들이 동일한 인스턴스 를 보고있기 때문에 배열에서 꺼낸 뒤 작업을 하면 복사한 객체에 영향 이 가기 때문에

여전히 위험한 코드이다.

떄문에 객체를 복사할 때 Deep Copy 를 해야하는 경우가 있다.

public class HashTable implements Cloneable {

   private Entry[] buckets = new Entry[10];

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

      public void add(Object key, Object value) {
         this.next = new Entry(key, value, null);
      }

//        public Entry deepCopy() {
//            return new Entry(key, value, next == null ? null : next.deepCopy());
//        }

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

   /**
    * TODO hasTable -> entryH[],
    * TODO copy -> entryC[]
    * TODO entryH[0] == entryC[0]
    *
    * @return
    */
    @Override
    public HashTable clone() {
        HashTable result = null;
        try {
            result = (HashTable)super.clone();
            result.buckets = this.buckets.clone(); // p82, shallow copy 라서 위험하다.
            return result;
        } catch (CloneNotSupportedException e) {
            throw  new AssertionError();
        }
    }
}

HashTable 클래스를 작성한다.

    @Override
    public HashTable clone() {
        HashTable result = null;
        try {
            result = (HashTable)super.clone();
            result.buckets = this.buckets.clone(); // p82, shallow copy 라서 위험하다.
            return result;
        } catch (CloneNotSupportedException e) {
            throw  new AssertionError();
        }
    }

해당 clone 메서드는 shallow copy 라서 위험하다.

shallow copy 는 새로만든 배열의 안에 있는 인스턴스들이 원본 배열과 동일한 인스턴스라는 뜻이다.

public class main {
   public static void main(String[] args) {
      HashTable hashTable = new HashTable();
      Entry entry = new Entry(new Object(), new Object(), null);
      hashTable.buckets[0] = entry;
      HashTable clone = hashTable.clone();
      System.out.println(hashTable.buckets[0] == entry); // true
      System.out.println(hashTable.buckets[0] == clone.buckets[0]); // true
   }
}

위의 코드를 통해 확인할 수 있다.

이러한 경우를 방지하기 위해 Deep Copy 를 해야한다.

public class HashTable implements Cloneable {

   private Entry[] buckets = new Entry[10];

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

      public void add(Object key, Object value) {
         this.next = new Entry(key, value, null);
      }

        public Entry deepCopy() {
            return new Entry(key, value, next == null ? null : next.deepCopy());
        }

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

   /**
    * TODO hasTable -> entryH[],
    * TODO copy -> entryC[]
    * TODO entryH[0] != entryC[0]
    *
    * @return
    */
   @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) {
               result.buckets[i] = this.buckets[i].deepCopy(); // p83, deep copy
            }
         }
         return result;
      } catch (CloneNotSupportedException e) {
         throw  new AssertionError();
      }
   }
}

위의 코드처럼 HashTable 을 수정하자.

        public Entry deepCopy() {
            return new Entry(key, value, next == null ? null : next.deepCopy());
        }

deepCopy 메서드를 추가하고,

   /**
    * TODO hasTable -> entryH[],
    * TODO copy -> entryC[]
    * TODO entryH[0] != entryC[0]
    *
    * @return
    */
   @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) {
               result.buckets[i] = this.buckets[i].deepCopy(); // p83, deep copy
            }
         }
         return result;
      } catch (CloneNotSupportedException e) {
         throw  new AssertionError();
      }
   }

clone 메서드를 변경한다.

주의할 점은 새로운 배열을 만드는 과정이 꼭 있어야한다는 점이다.

새로운 엔트리들을 새로 작성한 deepCopy() 를 통해 추가한다.

현재 deepCopy() 메서드는 재귀적으로 작성되어있기 때문에 StackOverFlowError 가 발생할 수 있다.

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

따라서 링크드 리스트인 경우 혹은 복사할 객체의 양이 많은 경우에는 위처럼 이터레이티브한 방법을 권장한다.

Deep Copy 를 했을 경우 참조하는 배열과 배열 안에 있는 객체의 참조가 원본 객체와 다르게 된다.

public class main {
   public static void main(String[] args) {
      HashTable hashTable = new HashTable();
      Entry entry = new Entry(new Object(), new Object(), null);
      hashTable.buckets[0] = entry;
      HashTable clone = hashTable.clone();
      System.out.println(hashTable.buckets[0] == entry); // true
      System.out.println(hashTable.buckets[0] == clone.buckets[0]); // false
   }
}

위의 코드에서 복사한 배열 안의 객체들이 서로 다른 걸 확인할 수 있다.

주의할 점은 clone 메서드 안에서 다른 메서드 를 호출할 때 재정의할 수 있는 메서드를 사용하지 않아야한다.

하위 클래스에서 오버라이딩 을 하게되면 동작이 바뀌기 떄문이다.

생성자에서도 마찬가지이다.

객체를 만드는 과정에서 호출하는 메서드하위 클래스에서 재정의 가능하게 하려면

까다로운 룰 을 만들거나 아예 재정의하지 못하게 막는 것 이 맞다.

public abstract class Shape implements Cloneable {

   private int area;

   public abstract int getArea();
}

위처럼 상속을 의도하는 계층 구조를 만든다면 Cloneable 인터페이스를 사용하지 않는게 좋다.

Cloneable 인터페이스를 선언하는 순간 해당 클래스를 상속받으려는 프로그래머에게 많은 부담을 주게된다.

그래도 상속 구조를 사용해야한다면 2가지 선택사항이 있다.

public abstract class Shape implements Cloneable {

   private int area;

   public abstract int getArea();


   /**
    * p84, 부담을 덜기 위해서는 기본 clone() 구현체를 제공하여,
    * Cloenable 구현 여부를 서브 클래스가 선택할 수 있다.
    * @return
    * @throws CloneNotSupportedException
    */
   @Override
   public Object clone() throws CloneNotSupportedException {
      return super.clone();
   }
}

첫번째 방법은 부모 클래스에서 clone 메서드를 직접 구현해 하위 클래스에서 구현할 필요가 없게하는 방법이다.

public abstract class Shape implements Cloneable {

   private int area;

   public abstract int getArea();


   /**
    * p84, 부담을 덜기 위해서는 기본 clone() 구현체를 제공하여,
    * Cloenable 구현 여부를 서브 클래스가 선택할 수 있다.
    * @return
    * @throws CloneNotSupportedException
    */
   @Override
   protected final Object clone() throws CloneNotSupportedException {
      return super.clone();
   }
}

다른 방법으로는 final 키워드를 사용해 하위 클래스에서의 재정의를 막는 방법이 있다.


객체를 만들때만 super.clone() 을 사용하고 고수준 API 를 호출하는 방법이있다.

put, get 같은 퍼블릭한(외부에 노출되어 있는) API 들이 고수준 API 의 예다.

고수준 API 를 사용하는 방법은 비교적 안정적이지만 느릴수도 있다.

혹시라도 clone 메서드가 스레드 세이프 한 메서드로 만들어야한다면 synchronized 키워드를 붙여 동기화 처리를 해야한다.

여기까지가 정석적인 clone 메서드를 사용하는 방법이다.


실제로는 생성자 를 사용하게된다.

import java.util.HashSet;
import java.util.TreeSet;

public class TreeSetExample {

   public static void main(String[] args) {
      Set<String> hashSet = new HashSet<>();
      hashSet.add("keesun");
      hashSet.add("whiteship");

      System.out.println("HashSet: " + hashSet);

      Set<String> treeSet = new TreeSet<>(hashSet);

      System.out.println("TreeSet: " + treeSet);
   }
}

대표적인 예로는 TreeSet 이 있다.

   // TreeSet 생성자
    public TreeSet(Collection<? extends E> c) {
        this();
        addAll(c);
    }

TreeSet 에 있는 생성자는 Collection 타입으로 파라미터를 받는다.

때문에 Collection 인터페이스를 구현하고 있는 다른 클래스를 생성자에 전달할 수 있다.

이렇게하면 생성자에서 copy 작업을 해준다.

clone 으로 카피를 하지 않고 생성자 를 통해 카피를 하게 된다.

public PhoneNumber(PhoneNumber phoneNumber) {
      this(phoneNumber.areaCode, phoneNumber.prefix, phoneNumber.lineNum);
      }

아니면 위의 코드처럼 카피 전용 팩터리 메서드 를 만들어도 된다.

카피 생성자를 썻을 떄 장점으로는

  • 명확하다.
  • 모호한 규약이 없다.
  • 필드에 final 을 사용할 수 있다. (clone 사용시 final 사용 불가)
  • 상위 타입에 해당하는 모든 인스턴스를 매개변수로 받을 수 있다.

Cloneable 사용은 지양 하고 카피 기능을 제공해야한다면 생성자나 팩터리 메서드 를 활용하도록 하자.

반응형

댓글