본문 바로가기
Language/Java

이펙티브 자바 - clone 재정의

by ocwokocw 2021. 10. 11.

- 이 글은 Effective Java 를 기반으로 작성되었습니다.

- Cloneable 인터페이스

Cloneable 은 객체의 복제를 허용한다는 사실을 알려주는 믹스인(mixin) 인터페이스이다. 하지만 문제점이 한 가지 있는데 clone 메소드가 없고 Object 의 clone 메소드의 접근제어자가 protected 라는것이다. 이렇게 되면 clone 메서드가 구현되어있으리라는 보장이 없는데 그럼에도 불구하고 널리 사용되므로 알아둘 필요가 있다.

 

Cloneable 은 protected 접근제어자를 갖는 Object 의 clone 메소드가 어떻게 동작할지를 정하는 역할을 한다. clone 메서드가 정의되어있지도 않은 Cloneable 인터페이스를 도대체 왜 신경써야하는지 의문이 들 수 있다. 그냥 Cloneable 인터페이스를 신경쓰지 않고 Object의 clone 을 override 하면 되는거 아닌가?

 

* @return     a clone of this instance.
* @throws  CloneNotSupportedException  if the object's class does not
*               support the {@code Cloneable} interface. Subclasses
*               that override the {@code clone} method can also
*               throw this exception to indicate that an instance cannot
*               be cloned.
* @see java.lang.Cloneable

 

위의 문구는 clone 메서드에 명시된 주석이다. 객체의 클래스가 Cloneable 인터페이스를 구현하지 않으면 CloneNotSupportedException 을 던진다고 되어있다.

 

public class CloneTest{

	public static void main(String[] args) throws CloneNotSupportedException {
		
		CloneTest test = new CloneTest();
		test.clone();
		
	}

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

.........

Exception in thread "main" java.lang.CloneNotSupportedException: effectivejava.clone.CloneTest
	at java.lang.Object.clone(Native Method)
	at effectivejava.clone.CloneTest.clone(CloneTest.java:14)
	at effectivejava.clone.CloneTest.main(CloneTest.java:8)

 

위의 코드를 실행해보면 clone() 메소드를 재정의해도 주석에 명시된 Exception이 발생한다. Cloneable 인터페이스를 구현해야만 오류가 발생하지 않는다.

 

인터페이스를 이런식으로 이용하는것은 좋은 사례가 아니다. 일반적으로 인터페이스란 사용자에게 어떤 기능이 수행될 수 있다는것을 알려주어야 한다. 하지만 Cloneable 은 상위 클래스의 protected 멤버가 어떻게 동작하는지 규정하는 용도로 사용하는데 이런식으로 인터페이스를 사용하지는 말도록 하자.


- Clone 메서드의 일반 규약

equals 의 일반규약은 상당히 엄격한 편이었지만 clone 의 일반규약은 다소 느슨한편이다. 객체 x에 대하여

  • x.clone() != x 가 참이다.
  • x.clone().getClass() == x.getClass() 는 참이지만 반드시 그래야만 하는것은 아니다.
  • x.clone().equals(x) 는 참이 되겠지만 반드시 그래야 하는것은 아니다. 객체 복사시 같은 클래스의 신규 객체가 생성되는데 내부 자료구조까지 복사해야할수도 있다. 이때 어떤 생성자도 호출하지 않는다.

3 의 어떤 생성자도 호출하지 않는다는 규약은 너무 엄격하다. final 인 경우 생성자를 사용해서 반환하도록 clone()을 구현할 수도 있기 때문이다.

 

반면 2의 규약은 너무 느슨하다. 프로그래머들은 하위 클래스에서 super.clone() 을 호출하면 하위 클래스 객체가 반환될거라고 생각한다. 하위 클래스에서 super.clone() 을 호출했을 때 상위클래스의 clone() 이 호출될것이고, 상위클래스의 clone() 에서 이런기능을 제공하려면 다시 super.clone() 을 호출해야 한다. 만일 상위클래스의 clone() 메소드 안에서 생성자를 호출해서 객체를 반환하면 반환된 객체의 클래스는 사용자가 원하던 클래스(하위 클래스의 clone() 을 호출했을때 기대한 결과)가 아니게 된다. 따라서 비-final 클래스의 clone을 재정의할 때는 반드시 super.clone을 호출해서 얻은 객체를 반환해야 한다. 이 매커니즘은 생성자들이 연결되는 매커니즘과 닮았지만 clone 에서는 이 사항이 강제적인것이 아니라는데에 차이점이 있다.


- 올바른 clone() 메서드 

실질적으로 Cloneable 인터페이스를 구현하는 클래스는 제대로 동작하는 public clone 메서드를 제공해야 한다. 또한 해당 클래스의 상위 클래스도 제대로된 public 또는 protected clone 을 제공해야 한다.

- 기본 자료형 + immutable 클래스의 clone

모든 Field 가 기본 자료형이거나 변경 불가능 클래스라면 clone 메소드 작성시 특별한 작업을 하지 않아도 괜찮다. Cloneable 인터페이스를 구현하고 Object 클래스의 clone 메소드에 접근할 수 있게 public clone 을 제공하면 된다.

 

public class PhoneNumber implements Cloneable{
 
    private final short areaCode;
    private final short prefix;
    private final short lineNumber;
 
    public PhoneNumber(int areaCode, int prefix, int lineNumber) {
        this.areaCode = (short) areaCode;
        this.prefix = (short) prefix;
        this.lineNumber = (short) lineNumber;
    }
 
    @Override public PhoneNumber clone() {
 
        try {
            return (PhoneNumber) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }

 

위의 clone 메소드에서는 Object 가 아니라 PhoneNumber 형을 반환했다. 이렇게 하면 사용하는 Client 는 형변환을 하지 않아도 된다.

- 변경 가능한 FIeld 의 Clone

복제할 객체가 변경 가능한 Field 를 참조하고 있다면 Clone 메소드 작성시 주의를 해야 한다. Stack 의 경우에는 원소들을 보관하고 있는 Object 의 배열을 갖고 있다. 

 

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

 

이런 상황에서 단순하게 clone() 을 구현하면 원본과 복사본의 elements 는 같은 객체를 참조하게 된다. 원본을 변경할 때 복사본도 변경되어 올바르게 동작하지 않을 수 있다. clone 은 또 다른 생성자다. 이런 변경 가능한 Field 를 참조하고 있는 클래스의 clone 작성시에는 내부구조도 복사해야 한다.

 

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

- clone 시 DeepCopy 가 필요한 경우 

위의 Stack 의 예제처럼 내부 구조를 복사해도 충분치 않은 경우가 있다. bucket 을 사용하는 HashTable 의 경우 아래와 같은 구조로 되어있을 것이다.

 

public class HashTable {
    
    private Entry[] buckets = ...;
    
    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;
        }
    }
    
    
    @Override public HashTable clone() {
        
        try {
            HashTable result = (HashTable) super.clone();
            result.buckets = this.buckets.clone();
            return result;    
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }

 

위의 코드처럼 작성하면 배열은 복사본을 갖지만 배열의 각 원소는 원래의 배열 원소와 동일한 연결리스트를 참조하게 된다. 이 문제를 해결하려면 동일한 연결리스트를 갖지 않도록 Entry 의 원소도 복사하도록 deepCopy 를 지원해야 한다.

 

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;
        }
        
        Entry deepCopy() {
            return new Entry(key, value, 
                next == null ? null : next.deepCopy());
        }
    }

 

clone 메소드도 수정해주도록 하자.

 

@Override public HashTable clone() {
        
        try {
            HashTable result = (HashTable) super.clone();
            
            result.buckets = new Entry[buckets.length];
            
            for(int i = 0; i < buckets.length; i += 1){
                if(buckets[i] != null) {
                    result.buckets[i] = buckets[i].deepCopy();
                }
            }
            return result;    
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }

 

buckets 복사시 자신의 길이를 할당하고, 각 버킷의 원소(Entry)를 deepCopy 한다. Entry 복사시에도 자신의 다음 참조를 재귀적으로 deepCopy 하므로 원본 객체와 복사본 간의 사이드 이펙트 없이 복제를 할 수 있게 되었다.

 

clone 메소드 재정의의 영역을 벗어나긴 하지만 위의 deepCopy 는 원소가 작을 때에만 잘 동작한다. 원소가 커지면 재귀호출시 원소마다 스택 프레임을 사용하기 떄문에 스택 오버플로우가 발생한다. 이를 방지하려면 재귀호출이 아닌 순환문으로 변경해야 하는데 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;
        }

 


- clone 과 스레드 세이프

알아둬야할 세부사항이 한 가지 더 있는데 다중 스레드에서 안전한 클래스에 대해 Cloneable 을 구현하려면 clone 메소드도 스레드 세이프(동기화 지원)하게 작성되어야 한다. Object의 clone은 스레드-세이프하지 않기 때문이다.


- 복자 생성자와 복사 팩토리

여태까지 clone을 올바르게 작성하는법을 알아보았지만 사실 clone을 제공하면서 이 모든 복잡한 세부사항을 지켜야 하는가에 대해서 근본적으로 생각해볼 필요가 있다. 만약 Cloneable 인터페이스를 구현하는 클래스를 계승한다면 clone 메소드를 구현해야 하지만 그런 경우가 아니라면 복제 기능을 제공하지 않는 쪽이 더 낫다.

 

객체의 복제가 필요하다면 아래와 같이 복사 생성자나 복사 팩토리를 제공하는 쪽을 생각해보자.

 

public HashTable(HashTable hashTable)

public static HashTable newInstance(HashTable hashTable)

댓글