본문 바로가기

Reading Record/이펙티브자바

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

클래스에서 clone을 재정의 하기위해선 해당 클래스에 Cloneable 인터페이스를 상속받아 구현하여야 한다. 그런데 정작 clone 메소드는 Cloneable 인터페이스가 아닌 Object에 선언되어있다. Cloneable 인터페이스에는 아무것도 선언되어있지 않은 빈 인터페이스이다. 그렇다면 Cloneable 인터페이스의 역할은 무엇일까?

Cloneable 인터페이스의 역할

Cloneable 인터페이스는 상속받은 클래스가 복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스이다.  Cloneable 인터페이스의 역할은 Object의 clone의 동작 방식을 결정한다. Cloneable을 상속한 클래스의 clone 메소드를 호출하면 해당 클래스를 필드 단위로 복사하여 반환한다. 만약, Cloneable을 상속받지 않고 clone 메소드를 호출하였다면 'CloneNotSupportedExcetion' 을 던진다. 

믹스인이란 클래스가 본인의 기능 이외에 추가로 구현할 수 있는 자료형으로, 어떤 선택적 기능을 제공한다는 사실을 선언하기 위해 쓰인다.

Object 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 메소드가 반환한다면, 이 식은 참이다. 관례상, 반환된 객체와 원본 객체는 독립적이어야 한다. 이를 만족하려면 super.clone으로 얻은 객체의 필드 중 하나 이상을 반환 전에 수정해야 할 수도 있다.

만약,clone 메소드가 super.clone 이 아닌 생성자를 호출해 얻은 인스턴스를 반환 하더라도 컴파일시에 문제가 되지않지만 해당 클래스의 하위 클래스에서 super.clone을 호출한다면 하위 클래스 타입 객체를 반환하지 않고 상위 클래스 타입 객체를 반환하여 문제가 생길 수 있다.

clone 메소드 재정의

public class Foo implements Cloneable {
    int num;

    public Foo() {
        System.out.println("---------------------");
        System.out.println("Foo constructor");
        System.out.println("---------------------");
    }

    public Foo(int num) {
        System.out.println("---------------------");
        System.out.println("Foo constructor");
        System.out.println("---------------------");
        this.num = num;
    }

    @Override
    public Foo clone() {
        try {
            System.out.println("---------------------");
            System.out.println("Foo Clone");
            System.out.println("---------------------");
            return (Foo) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new RuntimeException();
        }
    }
}

clone 메소드를 재정의 하기 위해서 Cloneable 인터페이스를 상속한다. Object의 clone 메소드는 'Checked Exception' 인 CloneNotSupportedException을 처리하도록 선언되어있다. clone 메소드 사용을 편하게 하기위해 throws 절을 try-catch 문으로 바꿔 clone메소드에서 예외 처리하도록 하자. 

책의 저자는 CloneNotSupportedException에 대해서 검사 예외(Checked Exception)가 아닌 비검사 예외(Unchecked Exception) 였어야 한다 라고 얘기하고있다. 이에 대해 내가 이해한 것은 CloneNotSupportedException은 Exception을 상속받는 Checked Exception 이다. 그런데 해당 예외는 개발자가 Cloneable을 상속 받지않고 super.clone을 호출 하였을 때 발생한다. 이는 개발자의 실수이고 충분히 예측할 만한 상황이다. 따라서 CloneNotSupportedException은 Checked Exception 보다 Unchecked Exception에 어울린다 라고 이해했다.

또한 Object의 clone 메소드는 Object를 반환하지만 위의 코드에선 Foo를 반환하게 했다. 이와 같은 일이 가능한 이유는 자바가 공변 반환 타이핑(covariant return typing) 을 지원하기 때문이다. 공변성에 대해선 다음 글을 참고하자. 공변성과 반공변성은 무엇인가?

 

공변성과 반공변성은 무엇인가?

Stephan Boyer의 What are covariance and contravariance?을 번역한 글이다. 공변성과 반공변성은 무엇인가? 서브타이핑은 프로그래밍 언어 이론에서 까다로운 주제다. 공변성과 반공변성은 오해하기 쉬운 주제이기 때문…

edykim.com

Foo 클래스의 clone메소드는 super.clone을 호출하여 Foo 클래스로 캐스팅하고 반환한다. 이때 super.clone은 Object의 clone을 호출하는데 Object의 clone은 native 메소드로 Foo 클래스의 각 필드를 기준으로 생서자를 호출하지 않고 객체를 복사한다. 각 필드를 '=' 을 이용해서 복사한다고 생각하면 쉽게 이해할 수 있을 것 같다. 중요한 점은 여기서 등장한다. 만약 클래스의 필드가 가변 객체를 참조하는 필드일 때 단순하게 super.clone만 반환하면 어떻게 될까?

가변 객체를 참조하는 클래스의 clone 재정의

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

    public Stack() {
        System.out.println("---------------");
        System.out.println("Stack constructor");
        this.elements = new Foo[DEFAULT_INITIAL_CAPACITY];
    }

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

    public int getSize() {
        return size;
    }

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

    public Foo pop() {
        if(size == 0) {
            throw new EmptyStackException();
        }
        Foo result = elements[--size];
        elements[size] = null;
        return result;
    }

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

    @Override
    public String toString() {
        StringBuilder stringBuilder = new StringBuilder();
        int i = 0;
        while (elements[i] != null) {
            stringBuilder.append(elements[i].num);
            stringBuilder.append(",");
            i++;
        }

        return stringBuilder.toString();
    }

    @Override
    public Stack clone() {
        try {
            return (Stack) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
}

Stack 클래스는 Foo 배열을 필드로 가지고있는 클래스이다. clone 메소드를 재정의 하기위해 Cloneable을 상속받았았고 재정의 한 clone 메소드는 super.clone을 반환한다. 
다음은 Stack 객체를 하나 생성하고 2개의 Foo객체를 push한다. 그리고 clone으로 새로운 Stack 객체를 만든 뒤 cloneStack 객체에 1개의 객체를 push 한다. 기존의 객체와 clone한 객체의 elements는 달라야 하는게 정상이라고 생각할 수 있다. 하지만 test 결과를 보자

Stack클래스의 clone 테스트
테스트 결과

위와 같이 테스트는 성공한다. Stack 클래스의 int size 필드는 기본타입으로 값이 정상적으로 복사되어 서로 다른 값을 갖지만, 참조 필드인 element의 경우 같은 주소를 가지게 된다. 이를 해결할 가장 간단한 방법은 다음과 같다.

    @Override
    public Stack clone() {
        try {
            Stack stack = (Stack) super.clone();
            stack.elements = this.elements.clone();
            return stack;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }

하지만 이 방법도 단점이 존재하는데 기존 객체와 복사된 객체의 객체 배열이 서로 다른 주소를 가지고 있어도 배열의 원소가 가지고 있는 Foo라는 객체는 같은 객체이다. 이 경우 반복자를 이용해 기존 elements가 가지고 있는 원소들을 복사된 배열에 맞게 새로 생성하면 쉽게 해결할 수 있을 것이다.
Effective Java에서는 위와 같은 경우를 HashTable을 예로 들어 설명하였다. HashTable 내부의 객체 버킷과 성능을 위해 구현한 연결 리스트들의 올바른 복사를 위해 재귀 호출, 반복자 등의 해결 방안들을 제시하였다. 자세한 사항은 책을 참고하면 더욱 도움이 된다.

clone 재정의시 주의사항

  • clone은 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야한다.
  • 복제할 수 있는 클래스를 만들기 위해 일부 필드에서 final 한정자를 제거해야 할 수도 있다.
  • 재정의될 수 있는 메소드를 호출하지 않아야한다.
public class Sup implements Cloneable{
    String type;

    public Sup() {
        this.type = "super";
    }

    public void overrideMe() {
        System.out.println("super method");
    }

    @Override
    public Sup clone() {
        try {
            overrideMe();
            return (Sup) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new RuntimeException();
        }
    }
}

public class Sub extends Sup {
    String temp;
    @Override
    public void overrideMe() {
        System.out.println("sub mehtod");
        System.out.println(temp);
        type = "sub";
    }

    @Override
    public Sub clone() {
        Sub clone = (Sub) super.clone();
        clone.temp = "temp";
        return clone;
    }

}

[아이템19]의 코드를 참고하여 코드를 구현했다. 만약 sub.clone을 호출하면 어떻게 될까?

Super 객체의 overrideMe가 호출될 것이라는 내 예상과 달리 Super객체에서 호출하여도 Sub객체의 overrideMe 함수가 호출되어 기존 객체의 type값이 "sub"로 변하였다. 또한 아직 temp 변수에 값이 할당되기 전에 호출되어 null이 출력되었다. 

  • Object의 clone 메소드는 CloneNotSupportedException을 던진단고 선언했지만 재정의한 메소드에서는 throws을 지우고 try-catch로 적절히 수정해야한다. 또한 접근 제한자를 public 으로 반환 타입은 클래스 자신으로 변경한다.
  • 상속용 크래스는 Cloneable을 구현해서는 안 된다. 
    • Object의 방식을 모방해 하위 클래스에 Cloneable 구현 여부를 선택하도록한다.
    • clone을 동작하지 않게 구현하고 하위 클래스에서 재정의하지 못하게 한다.
  • Cloneable을 구현한 스레드 안전 클래스를 작성할 때는 clone 메소드 역시 적절히 동기화해줘야 한다.

20.02.24 'clone 메소드 역시 적절히 동기화 해줘야한다' 를 잘못 이해하고 있었습니다! 

public class SynchronizedJob implements Cloneable {
    int cloneCount;
    Object lock;
    public SynchronizedJob() {
        this.cloneCount = 1000;
        this.lock = new Object();
    }

    public void reduceCountWithLock(CountDownLatch latch) {
        synchronized (lock) {
            latch.countDown();
            while (this.cloneCount >= 100) {
                try {
                    Thread.sleep(100L);
                    this.cloneCount -= 100;
                    System.out.println(Thread.currentThread().getName() + " 실행 현재 count" + cloneCount);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(Thread.currentThread().getName() + " 실행 현재 count" + cloneCount);
        }
    }

    @Override
    public SynchronizedJob clone() {
        try {
            SynchronizedJob clone = (SynchronizedJob) super.clone();
            return clone;
        } catch (CloneNotSupportedException e) {
            e.printStackTrace();
            throw new RuntimeException();
        }
    }
}

동기화가 적용된 reduceCountWithLock 메소드는 한번 호출되면 멤버로 가지고있는 cloneCount 값을 0으로 만들때까지 lock을 점유하는 메소드이다. 만약 해당 메소드가 동작할 때 clone을 호출하면 어떻게 될까? 해당 메소드는 Thread Safe하게 구현하였으므로 모든 일을 마치고 객체가 복제되는게 일반적인 생각이지만 다음 테스트 결과와 같이 그렇지 않다.

class SynchronizedJobTest {

    @Test
    @DisplayName("clone 메소드를 Thread safe 하게 구현해야 한다.")
    void testClone() throws InterruptedException{
        CountDownLatch latch = new CountDownLatch(2);
        CountDownLatch latch2 =  new CountDownLatch(1);
        SynchronizedJob synchronizedJob = new SynchronizedJob();

        Thread thread1 = new Thread(job(synchronizedJob,latch,latch2));
        Thread thread2 = new Thread(cloneJob(synchronizedJob,latch,latch2));
        thread1.setName("쓰레드 1");
        thread2.setName("쓰레드 2");
        thread1.start();
        thread2.start();

        latch.await();
    }

    Runnable job(SynchronizedJob sync, CountDownLatch latch, CountDownLatch latch2) {
        return () -> {
            sync.reduceCountWithLock(latch2);
            latch.countDown();
        };
    }

    Runnable cloneJob(SynchronizedJob sync, CountDownLatch latch,CountDownLatch latch2) {
        return () -> {
            try {
                latch2.await();
                SynchronizedJob clone = sync.clone();
                System.out.println(Thread.currentThread().getName() + "clone 객체 count 값 : " + clone.cloneCount);
                if(clone.cloneCount == 0) {
                    latch.countDown();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        };
    }
}

테스트가 끝나질 않는다.

재정의한 clone 메소드가 동기화 작업을 따로 하지않아 reduce 작업중에 객체를 복제해 cloneCount가 0이 되지 않았다. 따라서 clone 메소드가 별다른 동기화작업이 필요없더라도 스레드 안전 클래스라면 clone 메소드도 적절히 동기화 해줘야한다.

    @Override
    public SynchronizedJob clone() {
        try {
            synchronized (lock) {
                SynchronizedJob clone = (SynchronizedJob) super.clone();
                return clone;
            }
        } catch (CloneNotSupportedException e) {
            e.printStackTrace();
            throw new RuntimeException();
        }
    }

clone 메소드를 적절히 동기화 해주어 정상적으로 마무리된다.

복사 생성자와 복사 팩토리 메소드

Cloneable의 clone 메소드 대신 복사 생성자와 복사 팩토리 메소드를 사용하자.

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

public static Yum newInstance(Yum yum) {...};
  • clone 메소드 같은 생성자를 쓰지않는 객체 생성 메커니즘을 사용하지 않는다.
  • 엉성하게 문서화된 규약에 기대지 않는다.
  • final 필드 용법과도 충돌하지 않는다.
  • 불필요한 검사 예외나 형변환이 필요치않다.

또한 복사 생성자와 복사 팩토리 메소드는 해당 클래스가 구현한 '인터페이스' 타입의 인스턴스를 인수로 받을 수 있다. (변환 생성자, 변환 팩토리 메소드)

ArrayList의 변환 생성자, Collection을 구현한 클래스를 ArrayList로 복사한다.