춤추는 개발자

[item 13] 재정의는 주의해서 진행하라 본문

Developer's_til/Effective Java

[item 13] 재정의는 주의해서 진행하라

Heon_9u 2022. 10. 24. 19:29
728x90
반응형

 

 

 Cloneable은 복제해도 되는 클래스임을 명시하는 용도의 mixin interface지만, 의도한 목적을 제대로 이루지 못했다. 가장 큰 문제는 clone 메서드는 선언된 곳이 Cloneable이 아닌 Object이고, protected라는데 있다.

 

  Cloneable 인터페이스는 protected 메서드인 clone의 동작 방식을 결정한다. clone을 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환한다.

 

 clone 메서드의 일반 규약으로 Object 명세에서 가져온 설명은 다음과 같다.

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

또한 다음 식도 참이다.
x.clone().getClass() == x.getClass()

하지만, 이상의 요구를 반드시 만족해야 하는 것은 아니다.
한편, 다음 식도 일반적으로 참이지만 필수는 아니다.
x.clone().equals(x)

관례상, 이 메서드가 반환하는 객체는 super.clone을 호출해 얻어야 한다. 이 클래스와(Object를 제외한) 모든 상위 클래스가 이 관례를 따른다면 다음 식은 참이다.
x.clone().getClass() == x.getClass()

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

 강제성이 없다는 점만 빼면 생성자 연쇄와 비슷한 매커니즘이다.

 

쓸데없는 복사를 지양한다는 관점에서 보면 불변 클래스는 굳이 clone메서드를 제공하지 않는게 좋다. 이 점을 고려햐 PhoneNumber의 clone 메서드는 다음처럼 구현할 수 있다.

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

이 메서드가 동작하려면 PhoneNumber 클래스 선언에 Cloneable을 구현한다고 추가해야 한다. 자바가 공변 반환 타이핑을 지원하므로 권장하는 방식이다.

 

[ 가변 객체를 참조하는 클래스의 clone 메서드 ]

 간단했던 앞서의 구현이 클래스가 가변 객체를 참조하는 순간 재앙으로 돌변한다.

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

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

    // ...
}

 이 클래스에 단순히 clone 메서드가 super.clone의 결과를 그대로 반환한다면, elements 필드는 원본 Stack 인스턴스와 똑같은 배열을 참조하게 된다. 이러면 원본이나 복제본 중 하나만 변경해도 다른 하나도 수정되어 불변식을 해치게 된다.

 

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

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

 배열의 clone은 런타임 타입과 컴파일타임 타입 모두가 원본 배열과 똑같은 배열을 반환한다. 따라서 배열을 복제할 때는 배열의 clone 메서드를 사용하라고 권장한다.

 

[ HashTable용 clone 메서드 ]

 이번에는 HashTable용 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;
        }
    }

    // ...
}

 

 여기에 단순히 버킷 배열의 clone을 재귀적으로 호출해보면 문제가 생긴다. 복제본은 자신만의 버킷 배열을 갖지만, 이 배열은 원본과 같은 연결 리스트를 참조한다. 이를 해결하려면 각 버킷을 구성하는 연결 리스트를 복사해야 한다. 다음은 일반적인 해법이다.

 

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 AssertinError();
    }
}

 

 private 클래스인 HashTable.Entry는 깊은 복사를 지원하도록 보강되었다.

 HashTable의 clone 메서드는 먼저 적절한 크기의 새로운 버킷 배열을 할당한 다음, 원래의 버킷 배열을 순회하며, 비어있지 않은 각 버킷에 대해 깊은 복사를 수행한다.

 잘 작동하지만, 리스트가 길면 스택 오버플로를 일으킬 위험이 있다. 이 문제를 피하려면 재귀 호출 대신 반복자를 써서 순회하는 방향으로 수정해야 한다.

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

 

[ 변환 생성자와 변환 팩터리 ]

 이렇게 복잡한 작업보다 훨씬 나은 방법인 변환 생성자변환 팩터리가 있다. 단순히 자신과 같은 클래스의 인스턴스를 인수로 받는 생성자를 말한다.

// 변환 생성자
public Yum(Yum yum) {...};
// 변환 팩터리
public static Yum newInstance(Yum yum) {...};

 이는 객체 생성 메커니즘(생성자를 쓰지 않는 방식)을 사용하지 않으며, 정상적인 final 필드 용법과도 충돌하지 않으며, 불필요한 검사 예외를 던지지 않고, 형변환도 필요하지 않다.

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

 예를 들어 HastSet 객체 s를 TreeSet 타입으로 복제할 수 있다. clone으로는 불가능한 이 기능을 변환 생성자로는 간단히 new TreeSet<>(s)로 처리할 수 있다.

 

 

 

728x90
반응형