Post

Item 13 - clone 재정의는 주의해서 진행하라

clone 메서드가 선언된 곳이 Cloneable이 아닌 Object이며, 접근 제한자가 protected이기 때문에 Cloneable을 구현하는 것만으로는 외부 객체에서 clone 메서드를 호출할 수 없다.

이런 문제점에도 불구하고, Cloneable 방식은 널리 쓰인다. 이에 대해 알아보자.

Cloneable 인터페이스

  • Object의 protected 메서드인 clone의 동작 방식을 결정한다.
  • Cloneable을 구현한 클래스의 인스턴스에서 clone 호출 시, 그 객체 필드들을 하나하나 복사한 객체를 반환
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Car implements Cloneable {
    private List<String> features;
    private String model;
    private int year;
    private double price;
    
    public Car(List<String> features, String model, int year, double price) {
        this.features = features;
        this.model = model;
        this.year = year;
        this.price = price;
    }

    @Override // clone() 구현
    protected Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
    
    // getter, setter ...
}

🚑 주의사항

  • 애매모호한 clone 규약
    • x.clone() != x 반드시 true → 복사한 객체는 원본 객체와 독립적
    • x.clone().getClass() == x.getClass() 반드시 true → 복사한 객체와 원본 객체는 같은 클래스
    • x.clone().equals(x) true가 아닐 수도 있다. (일반적으로 참이지만, 필수는 아님)
  • clone 메서드는 피상적 복사를 지원한다.
    • 피상적 복사란, 단순하게 참조만 복사한다고 이해하면 쉽다.
    • 배열을 예로 들면, 내부 요소 하나하나 복사되는 것이 아니라 배열의 참조를 복사한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static void main(String[] args) throws CloneNotSupportedException {
    List<String> features = Arrays.asList("Navigation System", "Leather Seats");
    Car car = new Car(features, "Tesla Model S", 2024, 79999.99);
    Car clonedCar = (Car) car.clone();

    System.out.println("원본 차 가격: " + car.getPrice()); // 79999.99
    System.out.println("복제된 차 가격: " + clonedCar.getPrice()); // 79999.99

    System.out.println("원본 차 기능: " + car.getFeatures()); // [Navigation System, Leather Seats]
    System.out.println("복제된 차 기능: " + clonedCar.getFeatures()); // [Navigation System, Leather Seats]

    // 똑같은 참조를 가진다!
    System.out.println(System.identityHashCode(car.getPrice()));
    System.out.println(System.identityHashCode(clonedCar.getPrice()));

    // 똑같은 참조를 가진다!
    System.out.println(System.identityHashCode(car.getFeatures()));
    System.out.println(System.identityHashCode(clonedCar.getFeatures()));
}

clone 메서드 재정의 시 문제점

[clone 메서드의 특이한 메커니즘] 생성자를 호출하지 않고도 객체를 생성한다.

불변 객체라면 다음으로 충분하다.

  • Cloenable 인터페이스를 구현하고, clone 메서드를 재정의한다.
  • 이때, super.clone()을 사용한다.
1
2
3
4
5
6
7
8
@Override
public Car clone() {
    try {
        return (Car) super.clone();
    } catch (CloneNotSupportedException e) {
        throw new AssertionError();
    }
}

1️⃣ 가변 객체를 참조

가변 객체를 참조하는 객체를 복사하는 경우, 문제가 발생한다.

  • clone을 사용해서 복사한 인스턴스의 모든 필드는 같은 참조값을 가진다.
    • 원본과 복사본이 있을 때, 복사본의 가변 객체를 수정하면 원본 값도 수정된다는 의미다.
  • 생성자를 사용하면 이런 문제가 발생하지 않는다.
    • 하지만 유사 생성자인 clone은 객체의 불변성을 보장할 수 없다.

다음과 같이 재정의해서 불변성을 보장하자.

1
2
3
4
5
6
7
8
9
10
11
@Override
public Car clone() {
    try {
        Car result = (Car) super.clone();
        // 깊은 복사를 위해 features 리스트를 새로 생성
        result.features = new ArrayList<>(this.features);
        return result;
    } catch (CloneNotSupportedException e) {
        throw new AssertionError("Cloning not supported", e);
    }
}

2️⃣ 복잡한 가변 객체를 참조

  • 내부 엔트리를 통해서 특정 값에 접근할 수 있도록 구현한 HashTable이다.
  • clone을 사용하면 복사본이 원본의 엔트리를 통해서 값을 찾기 때문에 잘못된 값을 찾게 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
        }

    // getter, setter ...
}

다음과 같이 재귀적으로 구현해서 해결할 수도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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();
    }
}
  • 재귀호출로 연결리스트의 원소수만큼 스택 프레임을 소비한다. → 스택 오버플로우 발생 가능성!

재귀 호출 대신 아래의 코드처럼 반복자로 순회하자.

1
2
3
4
5
6
7
8
// 개선된 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;
}
  • 다른 방법으로 고수준 메서드를 만들어서 복제하는 방법도 있다.
    • 하지만 좋은 성능을 기대할 수 없고, Cloneable 아키텍처와 어울리지 않는 방법이다.

3️⃣ 재정의 가능한 메서드

clone 메서드에서 재정의 가능한 메서드를 호출하는 경우, 문제가 발생한다.

  • 하위 클래스에서 super.clone() 호출 시, 상위 클래스의 clone 메서드에서 하위 클래스의 재정의 된 메서드가 호출된다.
  • 예측할 수 없는 복제본이 만들어질 가능성이 생긴다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class Parent implements Cloneable {
    protected int value = 0;

    @Override
    public Parent clone() {
        super.clone();
        // 재정의 가능한 메서드 호출
        overrideableMethod();
        ...
    }

    public void overrideableMethod() {
        value += 1;
    }
}

public class Child extends Parent {
    @Override
    public Parent clone() {
        super.clone();
        ...
    }

    @Override
    public void overrideableMethod() {
        value += 2;
    }
}

Child.clone() -> Parent.clone() -> Child.overrideableMethod() 순으로 호출된다.

1
2
Parent instance = new Child();
instance.clone();

상위(Parent) 레벨에서 조정되어야 할 값들이 하위(Child)에 재정의된 메서드의 동작 방식대로 조정되고, 의도치 않은 방향으로 값이 복제되는 문제가 발생할 수 있다.

  • clone 메서드에서는 재정의 가능한 메서드는 호출해서는 안 된다.
  • 메서드 호출이 필요하면 private 도우미 메서드로 만들어서 사용해야 한다.

clone 메서드 대신 권장하는 방법

복사 생성자(자신과 같은 클래스의 인스턴스를 인수로 받는 생성자)와 복사 팩터리를 사용하자.

  • 생성자를 쓰지 않으며, 모호한 규약, 불필요한 검사 예외, final 용법 방해 등에서 벗어날 수 있다.
  • 해당 클래스가 구현한 인터페이스 타입의 인스턴스를 인수로 받을 수 있다.
  • 범용 컬렉션 구현체는 Collection이나 Map 타입을 받는 생성자를 제공하는데, 원본 구현 타입에 얽매이지 않고 클라이언트가 복제본의 타입을 결정할 수 있다.
    • ex. HashSet 객체 s를 TreeSet 타입으로 복제 가능하다. → new TreeSet<>(s)
  • 읽어볼 것) Josh Bloch on Design, “Copy Constructor versus Cloning”
1
2
3
4
5
6
7
8
9
10
// 복사 생성자
public Stack(Stack s) {
    this.elements = s.elements.clone();
    this.size = s.size;
}

// 복사 팩터리
public static Stack newInstance(Stack s) {
    return new Stack(s.elements, s.size);
}

💡 핵심 정리

  • 배열만이 clone() 메서드를 제대로 사용하는 유일한 예시.
  • 나머지는 웬만하면 복사 생성자복사 팩터리를 이용하자.

참고 🕶️) Unchecked Exception

  • 단순히 처리하기 쉽고 편하다는 이유만으로 RuntimeException을 선택하지는 말자.
  • Guideline: 클라이언트가 해당 예외 상황을 복구할 수 있다면 검사 예외를 사용하고, 해당 예외가 발생했을 때 아무것도 할 수 없다면, 비검사 예외로 만든다.
This post is licensed under CC BY 4.0 by the author.