Java

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

jun9.com 2022. 3. 31. 01:08

요약

  • 사전 개념
    • 얕은 복사(Shallow Copy) vs 깊은 복사(Deep Copy)
    • Checked Exception vs Unchecked Exception
  • clone() 메서드 사용방법
  • clone() 메서드 재정의 방법
    • 좋은 방법
    • 나쁜 방법

 


 

0. 사전 개념)

 

1) 얕은 복사(Shallow Copy) vs 깊은 복사(Deep Copy)

얕은 복사(Shallow Copy): '주소 값'을 복사한다는 의미. 주소를 공유하기 때문에 복사값이 수정되면 원본도 함께 수정된다.

깊은 복사(Deep Copy): '실제 값'을 새로운 메모리 공간에 복사. 원본과 복사본이 별개로 동작한다.

 

 

2) Checked Exception vs Unchecked Exception

uncheckedException은 예외 중 RuntimeException을 상속받은 클래스들로, 명시적으로 예외 처리를 하지 않아도 된다.

 

출처) https://madplay.github.io/post/java-checked-unchecked-exceptions

 

 

 


1. clone메서드 사용방법

 

Cloneable 인터페이스

public interface Cloneable { //복제해도되는 클래스임을 명시하고 싶을 때 사용
}

Cloneable에는 아무 메서드도 없다.

복제 가능한 클래스라는 사실을 VM에 알려주는 구분자이다.

 

 

하지만 이를 구현해야 Obejct.class의 clone()메서드를 어떻게 동작시킬지 명시할 수 있다.

(보통 인터페이스를 구현한다는 의미는 그 인터페이스에서 정의한 기능을 제공하겠다는 의미이지만,

하지만 Cloneable은 상위 클래스(Obejct)에 정의된 protected 메서드의 동작방식을 변경한다.)

 

때문에 Object.class의 clone()메서드를 사용하려면 implements Cloneable을 해야한다. 

Cloneable 인터페이스를 구현하지 않으면 clone()시 CloneNotSupportedException이 발생!

 

 

 

 


clone()메서드

어떤 객체가 있을 때 그 객체와 똑같은 객체를 복제해주는 기능

 

protected native Object clone() throws CloneNotSupportedException;

protected : 동일한 패키지 내에 존재하거나 상속된 클래스에서만 접근 가능

native: 자바가 아닌 언어(보통 C나 C++)로 구현한 후 자바에서 사용하려고 할 때 이용하는 키워드

 

궁금한점) native코드는 어디서 볼 수 있을까? openJDK > Source 다운 >>> lang코드는 j2se/src/share/native/java/lang/

 

 

 


clone() 사용방법

 

[기본 사용방법: 대상 - 불변객체]

public class PhoneNumber implements Cloneable{//복사 가능한 객체임을 명시, Object의 clone()동작
	@Override public PhoneNumber clone(){
    	try{
        	return (PhoneNumber) super.clone(); // Object를 반환하므로 형변환 해줘야함.
        }catch(CloneNotSupportedException e){ // checkedException임으로 작성해줘야함
        	throw new AssertionError();
        }
    }

}

1. implements Cloneable

2. CloneNotSupportedException catch or throw

3. clone()에서 얻은 객체가 Object이므로 형변환

 

Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면 그 객체의 필드들을 한하나 복사한 객체를 반환한다!

 

매우 편해보이지만 실제로는 의도와 다르게 동작하는 경우가 많다. 

 

 

 


clone메서드의 일반규약

  1. x.clone() != x 는 참이다. 원본 객체와 복사 객체는 서로 다른 객체이다.
  2. x.clone().getClass() == x.getClass() 는 참이다. 하지만 반드시 만족해야 하는 것은 아니다.
  3. x.clone().equals(x) 는 참이지만 필수는 아니다.
  4. x.clone().getClass() == x.getClass() //이를 만족하려면 super.clone으로 얻은 객체의 필드 중 하나 이상을 반환 전에 수정해야 할 수도 있다.

 

clone이 생성자와 비슷한 개념임을 알 수 있다. 

하지만 앞서 배웠던 equals, hashCode 일반규약들에 비해 매우 허술하다.

 

[비정상 케이스]

public class B extends A implements Cloneable{
    String grade;

    public B(String grade) {
        super();
        this.grade = grade;
    }

    public B clone() throws CloneNotSupportedException {
        return (B) super.clone(); //clone()은 Object가 아니라 A 타입
    }
}

class B extends A 케이스에서 비정상 케이스가 발생 

일반규약만으로 지키지 못하는 케이스가 있음을 의미한다. 

 

 

 

 

 


2. clone 재정의 방법

 

 

재정의1) 가변객체는 깊은 복사 형태로 바꿔야 한다.

가변 객체: 인스턴스가 생성된 이후에 내부 상태가 변경 가능한 상태.

 

아래는 스택을 구현한 클래스이다.

스택에는 int형인 size외에 가변객체인 배열이 있다.

import java.util.ArrayList;
import java.util.Arrays;
import java.util.EmptyStackException;

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

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

    public void push(Object o){
        ensuerCapacity();
        elements[size++] = 0;

    }

    public Object pop(){
        if(size == 0){
            throw new EmptyStackException();
        }

        Object result = elements[--size];
        elements[size] = null;
        return result;
    }

    private void ensuerCapacity() {
        if(elements.length == size){
            elements = Arrays.copyOf(elements, 2 * size + 1);
        }
    }

    // shallowCopy
    public Stack shallowClone() throws CloneNotSupportedException{
        return (Stack) super.clone(); //elements가 얕은 복사됨
    }

    //deepCopy: stack클래스를 복제하는 메서드
    public Stack clone() throws CloneNotSupportedException{
        Stack result = (Stack) super.clone();
        result.elements = elements.clone(); // ArrayList의 clone()
        return result;
    }



    public Object[] getElements() {
        return elements;
    }
}

shallowCopy와 deepCopy 두 방식을 구현했다.

shallowCopy는 Object의 clone의 기본동작을 그대로 사용했다.

deepCopy는 clone()안에서 elements에 대해서 clone()을 재귀호출한다. (배열이 각각의 값을 복사하도록 한다)

 

결과를 살펴보면,

public class Main {
    public static void main(String args[]) throws CloneNotSupportedException {

        Stack st1 = new Stack();
        Stack st2 = st1.shallowClone(); // 얕은 복사

        assert st1.getElements() == st2.getElements();// 주소값 같음

        st1 = new Stack();
        st2 = st1.clone(); // 깊은 복사

        assert st1.getElements() != st2.getElements(); // 주소값 다름
    }
}

참고) assert 예약어를 사용하려면 vmOption에 -ea 옵션을 추가해야 한다. (시스템클래스를 제외한 모든 클래스를 활성화 시키는 옵션. 이 옵션으로 assert 예약어를 사용할 수 있게 된다)

 

얕은 복사는 elements의 메모리 주소가 같은 것으로 보아, 복사가 제대로 이루어지지 않았음을 알 수 있다.

깊은 복사는 의도대로 복사가 되었다. (elements를 따로 복사해주었기 때문)

 

[참고] ArrayList의 clone()

public Object clone() {
        try {
            ArrayList<?> v = (ArrayList<?>) super.clone();
            v.elementData = Arrays.copyOf(elementData, size);
            v.modCount = 0;
            return v;
        } catch (CloneNotSupportedException e) {
            // this shouldn't happen, since we are Cloneable
            throw new InternalError(e);
        }

copyOf(Object배열을 새로 만들고 systemCopy를 이용해 데이터를 복사)로 원본 배열과 똑같은 배열을 반환한다. 

 

 


재정의2) 해시테이블용 clone메서드: clone을 재귀호출하는 것만으로 충분하지 않은 케이스

요약) 반복문을 사용하여, next 엔트리를 새로 생성하여 해결

 

아래는 HashTable을 구현했다.

Entry배열로 이루어져있고, Entry는 key, value값과 링크드리스트를 구현하기 위해 nextEntry를 가지고 있다.

 

public class HashTable implements Cloneable{
    private Entry[] buckets = new Entry[3]; //임의로 지정

    public HashTable() { // 임의로 지정
        buckets[0] = new Entry(0,"A");
        buckets[1] = new Entry(1,"B");
        buckets[2] = new Entry(2,"C");

        buckets[0].next = buckets[1];
        buckets[1].next = buckets[2];
    }

    @Override public HashTable clone(){ 
        try{
            HashTable result = (HashTable) super.clone(); // 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(); // Entry마다 deepCopy를 진행
                }
            }

            return result;
        }catch (CloneNotSupportedException e){
            throw new AssertionError();
        }
    }

    public Entry[] getBuckets() {
        return buckets;
    }

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

        Entry(Object key, Object value) {
            this.key = key;
            this.value = value;
        }

        Entry(Object 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());
//        }

        Entry deepCopy(){ 
          Entry result = new Entry(key, value, next); // 원본 값으로 entry를 새로 생성
          for(Entry p = result; p.next != null; p = p.next){ // 원본 entry를 순회
              p.next = new Entry(p.next.key, p.next.value, p.next.next);// next Entry는 새로 생성해 원본 주소값을 가리키지 않도록 한다.
          }
          return result;
        }
    }

}

 

재정의 방법1번처럼 구현한다면,

가변객체(배열)속 가변객체(Entry)서 얕은 복사가 일어나 아래와 같은 모양이 된다.

nextEntry가 복사한 다음 엔트리를 가리키지 못하고, 복사원본을 가리킨다..!

 

 

때문에 Entry를 새로 생성하여 값을 복사해주는 방법으로 위와 같은 원본과의 연결을 끊을 수 있다. (위 clone, deepCopy참조)

 

실제로 코드를 돌려보면, Entry

public class Main {

public static void main(String args[]) throws CloneNotSupportedException {

       // hashtable clone test
        HashTable ht = new HashTable();
        HashTable ht2 = ht.clone();
        assert ht.getBuckets() != ht2.getBuckets();
        assert ht.getBuckets()[0].next != ht2.getBuckets()[0].next; // 복사한 nextEnty의 주소가 원본의 주소값이 다름을 확인
        assert Arrays.stream(ht.getBuckets()).count() == Arrays.stream(ht2.getBuckets()).count();

    }
}

 

 

 


재정의3) 모든 필드를 새로 생성

1. clone으로 얻은 객체의 모든 필드를 초기 값으로 초기화

2. 원본 객체의 상태를 다시 생성 (고수준 메서드 호출)

 

위 HashTable 예시에서는

1. 새로운 배킷 배열을 초기화

2. 복제 테이블에서 put(key, value)를 호출하여 원본의 데이터를 넣어준다.

 

간단한 코드로 구현 가능하지만, 저수준에서 바로 처리할 때보다는 느리다. 

필드 단위 복사를 하지 않기 때문에 Cloneable 아키텍처와도 맞지 않다.

 

이는 Cloneable을 구현한 클래스를 확장해야 할 때 사용하면 좋다.

또한 clone()을 구현하는 것보다 아래 방식들이 더 유용하다.

 


(BEST) 변환 생성자(conversion constructor)와 변환 팩터리(conversion factory)

변환 생성자: 기본 자료형 하나를 받는 생성자

로 객체를 새로 생성하여 값을 복사해주는 방법이 더 낫다.

 

- 불필요한 검사 예외를 던지지 않고, 형변환도 필요하지 않다.

- 클래스가 구현한 인터페이스 타입의 인스턴스를 인수로 받을 수 있다.

(-> class B implements A{..}라면 B(A a)가 가능하다는 의미인듯하다)

 

clone보다는 변환 생성자, 변환 팩터리를 활용하고

이미 구현된 clone을 확장해야 하는 경우 위 재정의 방법들을 활용하여 구현하는 것이 좋다!