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

gang_shik·2022년 3월 2일
0

Effective Java 3장

목록 보기
4/5
  • cloneable은 복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스지만, 아쉽게도 의도하나 목적을 제대로 이루지 못했음

  • 왜냐하면 clone 메서드가 선언된 곳이 Cloneable이 아닌 Object이고 그마저도 protected라는데 있음

  • 그래서 Cloneable을 구현하는 것만으로는 외부 객체에서 clone 메서드를 호출할 수 없음

  • 리플렉션을 사용하면 가능하지만, 100% 성공하는 것도 아니고 접근 허용된 clone의 동작 방식을 결정함

  • Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환하며, 그렇지 않은 클래스의 인스턴스에서 호출하면 예외를 던짐

  • 하지만 이는 인터페이스를 상당히 이례적으로 사용한 예임, 그래서 따라하지 않고 실무에선 Cloneable을 구현한 클래스는 clone 메서드public으로 제공하며, 사용자는 당연히 복제가 제대로 이뤄지리라 기대함, 하지만 이 일반 규약은 생각보다 허술함

믹스인 인터페이스? 리플렉션?

믹스인 인터페이스

믹스인의 의미는 클래스가 구현할 수 있는 타입으로, 믹스인을 구현한 클래스에 원래의 주된 타입 외에도 특정 선택적 행위제공한다고 선언하는 효과를 줄 수 있다고 함

즉, 대상 타입의 주된 기능선택적 기능혼합한다고 해서 믹스인이라고 부름

여기서 Cloneable의 경우, 복제해도 되는 클래스로 쓰는 방식이지만 Cloneable을 구현하는 것만으로는 외부 객체에서 clone메서드를 호출할 수 없다고 함, 그리고 clone을 잘 동작하게끔 재정의하고 규약을 따져서 지켜야함

이 말 자체가 믹스인 인터페이스의 의도와 다름, 주된 타입외에도 선택적 행위 제공이라는 역할을 못 쓰기 때문에

리플렉션

자바 리플렉션은 구체적인 클래스 타입을 알지 못하더라도 그 클래스의 메소드와 타입 그리고 변수들을 접근할 수 있도록 해주는 자바 API

그러면 이 리플렉션 자체가 Cloneable을 그리고 정확히 알지 못하더라도 리플렉션의 성질을 활용하여 clone 메서드호출해서 접근할 수 있는 것임

일반적으로 재정의하여서 쓰면 아래와 같음

public class Example {
  public Example() {
    Node node = new Node();
    Node node1 = node.clone();
    // node과 node1의 해쉬 코드는 다르므로 서로 다른 클래스로 복제가 되었다.
    System.out.println(node.hashCode());
    System.out.println(node1.hashCode());
  }
 
  public static void main(String[] args) {
    new Example();
  }
}
 
// clone 함수를 사용하기 위해서는 Cloneable 인터페이스를 상속받는다.
class Node implements Cloneable {
  private String data;
 
  public void setData(String data) {
    this.data = data;
  }
 
  public void print() {
    System.out.println(data);
  }
 
  // clone을 재정의한다.
  @Override
  public Node clone() {
    try {
      // clonable 인터페이스를 상속받으면 복제함수를 사용할 수 있다.
      return (Node) super.clone();
    } catch (Throwable e) {
      return null;
    }
  }
}
  • 하지만 위와 같이 하지 않고 리플렉션을 활용하여 clone을 바로 활용하여서 쓸 수 있음 즉, clone 호출을 해서 쓸 수 있다는 것을 의미함, 구체적인 Cloneable을 모른다고 해도
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
 
public class Example {
  // 클래스 복제
  public Object clone(Object obj) {
    try {
      // 복제할 클래스 타입을 취득한다.
      Class<?> clz = obj.getClass();
      // 복제할 클래스의 생성자를 취득한다.
      Constructor<?> cons = clz.getDeclaredConstructor();
      // 클래스를 생성한다.
      Object clone = cons.newInstance();
      // 클래스 내부의 변수를 취득한다.
      for (Field field : clz.getDeclaredFields()) {
        // private, protected 접근자도 접근할 수 있게 바꾼다.
        field.setAccessible(true);
        // 원본 클래스로부터 데이터를 취득한다.
        Object data = field.get(obj);
        // 복제할 클래스로 데이테를 입력한다.
        field.set(clone, data);
      }
      // 복제된 클래스를 리턴한다.
      return clone;
    } catch (Throwable e) {
      return null;
    }
  }
 
  public Example() {
    // 클래스 생성
    Node node = new Node();
    // Data를 넣는다.
    node.setData("Hello world");
    
    // 클래스 복제
    Node node1 = (Node) clone(node);
 
    // 해쉬 코드가 다르니 서로 다른 클래스이다.
    System.out.println(node.hashCode());
    System.out.println(node1.hashCode());
    
    // 복제된 클래스에도 데이터가 잘 들어가 있다.
    node1.print();
  }
 
  public static void main(String[] args) {
    new Example();
  }
}
 
class Node {
  private String data;
 
  public void setData(String data) {
    this.data = data;
  }
 
  public void print() {
    System.out.println(data);
  }
}

Clone 메서드 일반 규약

  • 이 일반 규약은 생각보다 허술한데 아래와 같음

    • 이 객체의 복사본을 생성해 반환함, 복사의 정확한 뜻은 그 객체를 구현한 클래스에 따라 다를 수 있지만 일반적인 의도는 어떤 객체 x에 대해 다음 식은 참임을 의미함 x.clone() != x

    • 다음 식도 참이어야 함 x.clone().getClass() == x.getClass()

    • 하지만 이상의 요구를 반드시 만족해야 하는 것은 아님

    • 다음 식도 일반적으로 참이지만, 필수는 아님 x.clone().equals(x)

    • 관례상, 이 clone의 경우 반환하는 객체는 super.clone호출해 얻음

    • 이 클래스와(Object를 제외한) 모든 상위 클래스가 이 관례를 따른다면 다음 식은 참임 x.clone().getClass() == x.getClass()

    • 관례상 반환된 객체원본 객체독립적이어야함, 이를 만족하려면 super.clone으로 얻은 객체의 필드 중 하나 이상을 반환 전에 수정해야 할 수도 있음

  • 강제성이 없다는 점만 빼면 생성자 연쇄와 비슷한 매커니즘임, 생성자를 호출해 얻은 인스턴스를 반환해도 컴파일러는 불평하지 않을 것임

  • 이 클래스의 하위 클래스에서 super.clone을 호출한다면 잘못된 클래스의 객체가 만들어져, 결국 하위 클래스의 clone 메서드가 제대로 동작하지 않게됨

  • clone을 재정의한 클래스가 final이면 굳이 Cloneable을 구현할 이유가 없음

  • 제대로 동작하는 clone 메서드를 가진 상위 클래스를 상속해 Cloneable구현한다고 하면 super.clone 호출시 그렇게 얻은 것은 완벽한 복제본임, 클래스에 정의된 모든 필드는 원본 필드와 똑같은 값을 가짐

  • 모든 필드가 기본 타입이거나 불변 객체를 참조하면 완벽한 상태임, 하지만 쓸데없는 복사를 지양한다는 관점에서 불변 클래스를 굳이 clone 메서드를 제공하지 않는게 좋음

  • 아래와 같이 clone을 구현해볼 수 있음

@Override public PhoneNumber clone() {
		try {
				return (PhoneNumber) super.clone();
		} catch (CloneNotSupportedException e) {
				throw new AssertionError(); // 일어날 수 없는 일이다.
		}
}
  • 여기서 공변 반환 타이핑을 통해서 재정의한 메서드의 반환 타입은 상위 클래스의 메서드가 반환하는 타입의 하위 타입일 수도 있다고 설정하여서 클라이언트가 형변환을 하지 않아도 됨, 그래서 super.clone에서 얻은 객체를 반환하기전 형변환이 됨

  • try-catch로 감싼 것은 Object의 clone 메서드가 검사 예외를 던지도록 하기 위한 것임, PhoneNumber가 Cloneable을 구현하니 super.clone이 성공할 것임을 암(이는 예외가 비검사 예외였어야 한다는 신호임)

생성자 연쇄? 공변 반환 타이핑?

생성자 연쇄

생성자 연쇄는 생성자를 현재 객체에서 하나의 생성자에서 다른 생성자를 생성하는데 있어서 활용하여 연속적으로 생성자를 생성해서 쓴 것을 말함, 단순히 하나의 생성자를 만드는 것이 아닌 그 생성자를 기반으로 this() 혹은 super()등을 이용해서 생성자추가하는 것

clone 메서드의 방식이 super.clone을 쓰는 것이 어찌보면 이와 유사해 보여 생성자 연쇄 같다고 말한 것

공변 반환 타이핑

공변 반환 타이핑은 그 메서드가 오버라이딩될 때 더 좁은 타입으로 교체할 수 있다는 것을 의미함

clone 메서드의 경우 위의 예시에서 Object타입이 PhoneNumber로 반환된 것이 PhoneNumber라는 좁은 타입으로 교체된 것이고 이게 공변 반환 타이핑으로 볼 수 있음

비검사 예외?

비검사 예외

비검사 예외는 개발자가 예외(try-catch)로 잡지 말라고 하며 심각한 상황에서 발생하는 예외임

이런 예외는 런타임, 널포인터, 스택오버플로우, 그 외 에러등 시스템에 치명적으로 일어나기 때문에 예외 검사의 목적으로 처리할 수 있는 수준이 아님을 말함

그 외에 검사 예외는 어플리케이션 수행 중에 일어날법한 예외를 검사하고 대비하는 목적임, 예를 들면 interrupt 호출시 생기는 예외 등


가변 객체 clone

  • 만약 앞에서 한 구현을 가변 객체참조하면 얘기가 다름

  • 아이템 7에서 본 Stack을 예로 들면 일단 Stack은 아래와 같음

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

		public Stack() {
				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;
		}

		/**
		 * 원소를 위한 공간을 적어도 하나 이상 확보한다.
		 * 배열 크기를 늘려야 할 때마다 대략 두 배씩 늘린다.
		 */
		private void ensureCapacity() {
				if (elements.length == size) 
						elements = Arrays.copyOf(elements, 2 * size + 1);
		}
}
  • 여기서 단순하게 super.clone으로 결과를 그대로 반환하면 elements 필드가 똑같은 배열을 참조하여서 원본이나 복제본 중 하나를 수정하면 다른 하나도 수정되어 불변식을 해쳐서 오류가 남

  • clone 메서드는 사실상 생성자와 같은 효과를 냄, clone은 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식보장해야함

  • 이를 아래와 같이 재귀적으로 호출해서 해결할 수 있음

@Override public Stack clone() {
		try {
				Stack result = (Stack) super.clone();
				result.elements = elements.clone();
				return result;
		} catch (CloneNotSupportedException e) {
				throw new AssertionError();
		}
}
  • elements.clone의 결과를 Object[]로 형변환할 필요는 없음, 배열의 clone은 모두가 원본 배열과 똑같은 배열을 반환함

  • 여기서 elementsfinal이면 작동하지 않음, 이는 Cloneable 아키텍처가변 객체참조하는 필드는 final로 선언하라는 일반 용법과 충돌함

  • 그래서 복제할 수 있는 클래스를 만들기 위해 일부 필드에서 final 한정자를 제거해야 할 수 있음

객체의 불변식?

객체의 불변식

객체의 불변식은 프로그램이 실행되는 동안, 혹은 정해진 기간 동안 객체(내부 필드)가 반드시 만족해야 하는 조건임

clone을 수행했을 때 복제된 객체가 프로그램 실행 중 반드시 만족해야 하는 조건이 있으면 원본 객체와 동일하게 지킬 수 있어야 한다는 의미임

그래서 Stack의 경우 이 사례를 지켜야 하기 때문에 clone을 통해서 이를 지키게끔 바꿔서 위와 같이 쓴 것임


해시테이블 clone 메서드

  • 해시테이블의 경우 clone을 재귀적으로 호출하는 것만으로 충분하지 않을 수 있음, 해시 테이블 내부는 버킷들의 배열이고 각 버킷은 키-값 쌍을 담는 연결리스트의 첫번째 엔트리를 참조함
public class HashTable implements Cloneable {
		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;
            }
        }
        ... // 나머지 코드 생략
}
  • 여기서 위의 HashTable을 단순하게 재귀적으로 호출하면 아래와 같음, 이는 복제본은 자신만의 버킷 배열을 갖지만, 이 배열은 원본과 같은 연결리스트를 참조하여 원본과 복제본 모두 예기치 않게 동작할 가능성이 생김
@Override public HashTable clone() {
		try {
				HashTable result = (HashTable) super.clone();
				result.buckets = buckets.clone();
				return result;
		} catch (CloneNotSupportedException e) {
				throw new AssertionError();
		}
}
  • 이를 해결하기 위해서 각 버킷을 구성하는 연결리스트를 복사해야함
public class HashTable implements Cloneable {
		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;
            }
            
            // 이 엔트리가 가리키는 연결 리스트를 재귀적으로 복사
            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();
            }
        }
        ... // 나머지 코드 생략
}
  • HashTable.Entry깊은 복사를 지원하도록 보강됨, HashTable의 clone 메서드는 먼저 적절한 크기의 새로운 버킷 배열을 할당한 다음 원래의 버킷 배열을 순회하며 비지 않은 각 버킷에 대해 깊은복사를 수행함, 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;
}

깊은 복사?

깊은 복사

깊은 복사는 실제 값을 새로운 메모리 공간에 복사하는 것을 의미함

즉, 위처럼 깊은 복사를 하게 되면 원본과 복사본에 영향이 서로 미치지 못함

이에 비해 얕은 복사의 경우 주소 값을 복사하기 때문에, 참고하고 있는 실제값은 같음, 그래서 원본을 변경해도 복사본 역시 변경되는 서로 영향을 끼침

위처럼 아예 HashTable의 경우 깊은 복사로 원본에 영향을 받지 않음


복잡한 가변 객체

  • 복잡한 가변 객체를 복제하는 방법은 super.clone을 호출하여 얻은 객체의 모든 필드초기 상태설정한 다음, 원본 객체의 상태를 다시 생성하는 고수준 메서드들을 호출함(해시테이블의 경우 put 메서드를 통해 호출해서 처리하는 등)

  • 간단하고 괜찮지만 저수준에서 바로 처리할 때보다는 느림, 전체 Cloneable 아키텍처와 어울리지 않음, 그리고 clone 메서드에서 재정의 될 수 있는 메서드를 호출하지 않아야함, 그렇지 않으면 자신의 상태를 교정할 기회를 잃게 되어 원본과 복제본의 상태가 달라질 가능성이 큼(해당 메서드는 final이거나 private이어야 함)

  • 여기서 재정의한 clone 메서드에서는 예외를 던지지 않음, 그래서 public인 clone 메서드에서는 throws절을 없애야함, 그래야 그 메서드 사용이 편함

  • 상속해서 쓰기 위한 클래스 설계 방식 두 가지 중 어느쪽이든 상속용 클래스는 Cloneable을 구현해서는 안됨

  • Object의 방식을 모방하여 clone 메서드를 protected로 두고 예외를 던질 수 있음, 이러면 Cloneable의 구현 여부를 하위 클래스에서 선택할 수 있음

  • 다른 방법으론 아래와 같이 clone을 동작하지 않게 구현해놓고 하위 클래스에서 재정의하지 못하게 할 수도 있음

@Override
protected final Object clone() throws CloneNotSupportedException {
	throw new CloneNotSupportedException();
}
  • 그리고 Cloneable을 구현한 스레드 안전 클래스 작성시 clone 메서드 역시 적절히 동기화해줘야함(clone을 재정의하고 동기화도 필요)

  • 위와 같이 복잡한 경우는 드뭄, Cloneable을 이미 구현한 클래스를 확장한다면 어쩔 수 없이 clone을 잘 작동하도록 구현해야함

  • 그렇지 않은 상황에서는 복사 생성자복사 팩터리라는 더 나은 객체 복사 방식을 제공할 수 있음

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

public Yum(Yum yum) { ... }
  • 복사 팩터리복사 생성자를 모방한 정적 팩터리
public static Yum newInstance(Yum yum) { ... }
  • 이 방식이 오히려 Cloneable/clone 방식보다 나은면이 많음

  • 그리고 해당 클래스가 구현한 인터페이스 타입의 인스턴스를 인수로 받을 수 있음, 이를 이용하면 클라이언트는 원본의 구현 타입에 얽매이지 않고 복제본의 타입을 직접 선택할 수 있음

profile
측정할 수 없으면 관리할 수 없고, 관리할 수 없으면 개선시킬 수도 없다

0개의 댓글

관련 채용 정보