모든 객체의 공통 메서드

Object에 정의된 비-final 메서드(equals, hashCode, toString, clone, finalize)의 명시적인 일반 규약들에 대해서 알아보자.(종료자 finalize는 제외) 추가로 Object의 메서드는 아니지만 특성이 비슷한 Comparable.compareTo도 알아보자.

규칙10 : equals를 재정의할 때는 일반 규약을 따르라

equals를 재정의하지 않아도 되는 경우 1. 각각의 객체가 고유하다. 1. 값(value) 대신 활성 개체(active entity)를 나타내는 Thread 같은 클래스가 이 조건에 부합. 2. 클래스에 논리적 동일성 검사 방법이 있건 없건 상관없다. 1. Random 클래스는 equals 메서드가 큰 의미 없다. 3. 상위 클래스에서 재정의한 equals가 하위 클래스에서 사용하기에도 적당하다. 4. 클래스가 private 또는 package-private로 선언되었고, equals 메서드를 호출할 일이 없다. 1. 하지만 저자는 재정의해서 throw new AssertionError();를 선언하라고 한다. 5. 최대 하나의 객체만 존재하도록 제한하는 클래스.

equals를 재정하는 것이 바람직할 때 : 객체 동일성(object equality)이 아닌 논리적 동일성(logical equality)의 개념을 지원하는 클래스일 때, 그리고 상위 클래스의 equals가 하위 클래스의 필요를 충족하지 못할 때 재정의해야 한다.

equals 메서드는 동치 관계를 구현한다.

반사성: null이 아닌 참조 x가 있을 때, x.equals(x)는 true를 반환한다. 모든 객체는 자기 자신과 같아야 한다는 뜻이다.

대칭성: null이 아닌 참조 x와 y가 있을 때, x.equals(y)는 y.equals(x)가 true일 때만 true를 반환한다. 두 객체에게 서로 같은지 물으면 같은 답이 나와야 한다는 것이다.

//대칭성 위반 클래스!!
public final class CaseInsensitiveString{
    private final String s;

    public CaseInsensitiveString(String s){
        if( s == null)
            throw new NullPointerException();
        this.s = s; 
    }

    //대칭성 위반 !! 
    @Override
    public boolean equals(Object o){
        if(o instanceof CaseInsensitiveString){
            return s.equalsIgnoreCase(((CaseInsensitiveString)o).s);
        }
        if(o instanceof String){ //한 방향으로만 정상 동작! 
            return s.equalsIgnoreCase((String)o); 
        }
        return false; 
    }
}
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish";

cis.equals(s)는 True를 반환할 것이다. 하지만 s.equals(cis)는 false를 반환한다. 왜냐하면 String은 CaseInsensitiveString이 뭔지 모르기 때문이다.

그러므로 이 문제를 방지하려면 CaseInsensitiveString의 equals 메서드가 String 객체와 상호작용하지 않도록 해야 한다.

@Override
public boolean equals(Object o){
    return o instanceof CaseInsensitiveString &&
            ((CaseInsensitiveString)o).s.equalsIgnoreCase(s); 
}

추이성: null이 아닌 참조 x,y,z가 있을 때, x.equals(y)가 true이고 y.equals(z)가 true이면 x.equals(z)도 true이다.

상위 클래스에 없는 새로운 값 컴포넌트를 하위 클래스에 추가하는 상황을 생각해 보자. 다시 말해 equals가 비교할 새로운 정보를 추가한다는 뜻이다.

public class Point{
    private final int x;
    private final int y;

    public Point(int x, int y){
        this.x = x;
        this.y = y;
    }

    @Override
    public boolean equals(Object o){
        if(!(o instanceof Point)){
            return false;
        }
        Point p = (Point)o;
        return p.x == x && p.y ==y; 
    }
}

이 클래스를 계승하여, 색상 정보를 추가해보자.

public class ColorPoint extends Point{
    private final Color color; 

    public ColorPoint(int x, int y, Color color){
        super(x, y);
        this.color = color; 
    }
}

ColorPoint 클래스의 equals 구현은 어떻게 해야 할까? 구현을 생략한다면 Point의 equals가 그대로 상속되어서 추가된 색상 정보는 비교하지 못한다.

//대칭성 위반 !!
@Override
public boolean equals(Object o){
    if(!(o instanceof ColorPoint)){
        return false; 
    }
    return super.equals(o) && ((ColorPoint)o).color == color;
}

위와 같이 equals를 구현했다면, 아래 코드에서 대칭성이 어떻게 위반되는것이 명확하게 확인된다.

Point p = new Point(1,2);
ColorPoint cp = new ColorPoint(1,2,Color.RED);

p.equals(cp); // true 왜냐면 Pointer의 equals는 color를 비교하지 않으니까  
cp.equals(p); // false 왜냐면 ColorPoint의 equals에서 color가 같을 수가 없으니까

그렇다면 ColorPoint의 equals를 수정해서 Point 객체와 비교할때는 색상 정보를 무시하도록 하면 어떻게 될까?

//추이성 위반!!
@Override
public boolean equals(Object o){
    if(!(o instanceof Point))
        return false; 

        //o가 Point 객체이면 색상은 비교하지 않는다. 
        if(!(o instanceof ColorPoint))
            return o.equals(this);

        //o가 ColorPoint이므로 모든 정보를 비교
        return super.equals(o) && ((ColorPoint) o).color == color; 
}

이렇게 하면 대칭성은 보존되지만 추이성은 깨진다.

ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);

p1.equals(p2)와 p2.equals(p3)는 모두 true를 반환하지만 p1.equals(p3)는 false를 반환한다.

이 문제의 해결책은 무엇인가? 사실 이것은 객체 지향 언어에서 동치 관계를 구현할 때 발생하는 본질적 문제다. 객체 생성가능 클래스를 계승하여 새로운 값 컴포넌트를 추가하면서 equals 규악을 어기지 않을 방법은 없다.

equals 메서드를 구현할 때 instanceof 대신 getClass 메서드를 사용하면 기존 클래스를 확장하여 새로운 값 컴포넌트를 추가하더라도 equals 규약을 준수할 수 있다?

//리스코프 대체 원칙 위반
@Override public boolean equals(Object o){
    if(o == null || o.getClass() != getClass())
        return false;
    Point p = (Point)o;
    return p.x == x && p.y == y;
}

이렇게 하면 같은 클래스의 객체만 비교하게 된다. 하지만 아래의 예제를 보면 올바르지 않다는 것을 알 수 있다.

//단위 원 상의 모든 점을 포함하도록 unitCircle 초기화 
private static final Set<Point> uniCircle;
static{
    unitCircle = new HashSet<Point>();
    unitCircle.add(new Point(1, 0));
    unitCircle.add(new Point(0, 1));
    unitCircle.add(new Point(-1, 0));
    unitCircle.add(new Point(0, -1));
}

public static boolean onUnitCircle(Point p){
    return unitCircle.contatins(p); 
}
public class CounterPoint extends Point{
    private static final AtomicInteger counter = new AtomicInteger();

    public CounterPoint(int x, int y){
        super(x, y);
        counter.incrementAndGet();
    }   

    public int numberCreated() { return counter.get(); } 
}

리스코프 대체 원칙은 어떤 자료형의 중요한 속성은 하위 자료형에도 그대로 유지되어서, 그 자료형을 위한 메서드는 하위 자료형에도 잘 동작해야 한다는 원칙이다. 그런데 CounterPoint 객체를 onUnitCircle 메서드의 인자로 넘기는 경우를 생각해보자. Point 클래스의 equals 메서드가 getClass를 사용하고 있다면, onUnitCircle 메서드는 CounterPoint 객체의 x나 y값에 상관없이 무조건 false를 반환할 것이다.

이는 onUnitCircle 메서드가 이용하는 HashSet 같은 컬렉션이 객체 포함여부를 판단할 때 equals를 사용하기 때문이며, CounterPoint객체는 어떤 Point객체와도 같을 수 없기 때문이다.

객체 생성 가능 클래스를 계승해서 새로운 값 컴포넌트를 추가할 만족스러운 방법이 없긴 하지만, 문제를 깔끔하게 피할 수 있는 방법은 하나 있다. Point를 계승해서 ColorPoint를 만드는 대신, ColorPoint안에 private Point 필드를 두고, public 뷰(view) 메서드를 하나 만드는 것이다. 이 뷰 메서드는 ColorPoint가 가리키는 위치를 Point 객체로 반환한다.

//equals 규약을 위반하지 않으면서 값 컴포넌트 추가 
public class ColorPoint{
    private final Point point; 
    private final Color color;

    public ColorPoint(int x, int y, Color color){
        if(color == null)
            throw new NullPointerException();
        point = new Point(x, y);
        this.color = color; 
    }

    //ColorPoint의 Point 뷰 반환
    public Point asPoint(){
        return point;
    }

    @Override
    public boolean equals(Object o){
        if(!(o instanceof ColorPoint))
            return false; 
        ColorPoint cp = (ColorPoint)o;
        return cp.point.equals(point) && cp.color.equals(color);
    }
}

자바의 기본 라이브러리 가운데는 객체 생성 가능 클래스를 계승하여 값 컴포넌트를 추가한 클래스도 있다. 일례로 java.sql.Timestamp는 java.util.Date를 계승하여 nanoseconds 필드를 추가한 것이다. Timestamp 클래스의 equals 메서드는 대칭성을 위반하므로 Timestamp 객체와 Date 객체를 같은 컬렉션에 보관하거나 섞어 쓰면 문제가 생길 수 있다.

abstract로 선언된 클래스와 값 필드를 추가하는 것은 equals 규약을 어기지 않고도 가능하다. 추상 클래스는 객체를 생성할 수 없으므로 앞서 살펴본 문제들은 생기지 않을 것이다.

일관성: null이 아닌 참조 x와 y가 있을 때, equals를 통해 비교되는 정보에 아무 변화가 없으면, x.equals(y) 호출 결과는 호출 횟수에 상관없이 항상 같아야 한다.

신뢰성이 보장되지 않는 자원들을 비교하는 equals를 구현하는 것을 삼가라. 예를 들어 java.net.URL의 equals 메서드는 URL에 대응되는 호스트의 IP 주소를 비교하여 equals의 반환값을 결정한다. 문제는 호스트명을 IP 주소로 변환하려면 네트워크에 접속해야 하므로, 언제나 같은 결과가 나온다는 보장이 없다는 것이다.

Null에 대한 비 동치성: null이 아닌 참조 x에 대해서, x.equals(null)은 항상 false이다.

instanceof 연산자는 첫 번째 피연산자가 null이면 두 번째 피연산자의 자료형에 상관없이 무조건 false를 반환하므로 따로 null인지 검사할 필요없다.

String클래스의 equals 메서드

public boolean equals(Object anObject){
    if(this == anObject){ //1번 
        return true; 
    }
    if(anObject instanceof String){ //2번
        String anotherString = (String)anObject;  //3번

        int n = value.length; //4번
        if(n == anotherString.value.length){
            char v1[] = value;
            char v2[] = anotherString.value; 
            int i = 0;
            while(n-- != 0){
                if(v1[i] != v2[i])
                    return false;
                i++;
            }
            return true;
        }
    }
    return false;
}

훌륭한 equals 메서드를 구현하기 위해 따라야 할 지침들이다.

  1. == 연산자를 사용하여 equals의 인자가 자기 자신인지 검사하라.

  2. instanceof 연산자를 사용하여 인자의 자료형이 정확한지 검사하라.

  3. equals의 인자를 정확한 자료형으로 변환하라.

  4. "중요" 필드 각각이 인자로 주어진 객체의 해당 필드와 일치하는지 검사한다.

규칙11 : equals를 재정의할 때는 반드시 hashCode도 재정의하라

hashCode 일반 규약 1. 응용프로그램 실행 중에 같은 객체의 hashCode를 여러 번 호출하는 경우, equals가 사용하는 정보들이 변경되지 않았다면, 언제나 동일한 정수가 반환되어야 한다. 다만 프로그램이 종료되었다가 다시 실행되어도 같은 값이 나올 필요는 없다. 2. equals(Object) 메서드가 같다고 판정한 두 객체의 hashCode 값은 같아야한다. 3. equals(Object) 메서드가 다르다고 판정한 두 객체의 hashCode 값은 꼭 다를 필요는 없지만 서로 다른 hashCode 값이 나오면 해시 테이블의 성능이 향상될 수 있다는 점은 이해해라.

규칙12 : toString은 항상 재정의하라

가능하다면 toString 메서드는 객체 내의 중요 정보를 전부 담아 반환해야 한다.

규칙13 : clone을 재정의할 때는 신중하라

Cloneable 인터페이스는 복제를 허용하는 객체라는 것을 알리는 목적으로 사용하는 믹스인(Mixin) 인터페이스다(Cloneable 인터페이스는 아무런 추상 메서드도 가지고 있지 않다).

믹스인(Mixin)이란 "원래 타입"에 어떤 부가적인 행위를 추가로 구현했다는 것을 나타내는 타입. ex) Comparable 인터페이스

Cloneable 인터페이스가 하는일은 무엇인가? protected로 선언된 Object의 clone 메서드가 어떻게 동작할지 정한다. 만일 어떤 클래스가 Cloneable을 구현하면, Object의 clone 메서드는 해당 객체를 필드 단위로 복사한 객체를 반환한다. Cloneable을 구현하지 않은 클래스라면 clone 메서드는 CloneNotSupportedException을 던진다.

인터페이스를 굉장히 괴상하게 이용한 사례로, 따라하면 곤란하다. 일반적으로 인터페이스를 구현한다는 것은 클래스가 무슨 일을 할 수 있는지 클라이언트에게 알리는 것이다. 그런데 Cloneable의 경우에는 상위 클래스의 protected 멤버가 어떻게 동작할지 규정하는 용도로 쓰이고 있다.

protected native Object clone() throws CloneNotSupportedException;

cf) native 키워드는 자바가 아닌 언어(보통 C나 C++)로 구현한 후 자바에서 사용하려고 할때 이용하는 키워드이다. 자바로 구현하기 까다로운 것을 다른 언어로 구현해서, 자바에서 사용하기 위한 방법이다.

릴리스 1.6에서도 Cloneable은 해당 인터페이스를 구현하는 클래스가 어떤 책임을 져야 하는지 상세히 밝히지 않는다. 실질적으로 Cloneable 인터페이스를 구현하는 클래스는 제대로 동작하는 public clone 메서드를 제공해야 한다.

//Clone 사용 예시 만들어봤다. 
public class CloneTest implements Cloneable{
    private final int a;
    private final int b;
    private final int c = 100;

    public CloneTest(){
        a = 1;
        b = 2;
    }

    @Override
    public CloneTest clone() {
        try {
            return (CloneTest) super.clone();
        } catch(CloneNotSupportedException e) {
            e.printStackTrace();
        }
        return null;
    }

    //setter getter ...
}

위의 clone 메서드는 Object가 아니라 CloneTest를 반환한다. 1.5버전부터는 이렇게 할 수 있을 뿐 아니라, 그렇게 하는 것이 바람직하다. 제네릭의 일부로 공변 반환형(covariant return type)이 도입되었기 때문이다. 다시 말해서, 재정의 메서드의 반환값 자료형은 재정의 되는 메서드의 반환값 자료형의 하위 클래스가 될 수 있다. 덕분에 재정의 메서드는 반환될 객체에 대한 더 많은 정보를 제공 할 수 있고, 클라이언트는 형변환을 하지 않아도 된다. 여기서 강조하고 싶은 일반 원칙 하나는, 라이브러리가 할 수 있는 일을 클라이언트에게 미루지 말라는 것이다.

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

추가적으로 Object clone을 오버라이딩하면 위의 코드의 모습인데 throws CloneNotSupportedException는 생략 할 수 있다. public clone 메서드는 사실 해당 선언을 반드시 생략해야 한다. 컴파일 할 때 예외처리 여부를 검사하도록 강요하는 checked exception 메서드는 사용하기 불편하기 때문이다.

super.clone()을 하지 않으면 Object의 clone 구현 동작에 의존하지 않으므로 클래스가 Cloneable을 구현할 이유가 없다. 그러니 비-final 클래스에 clone 을 재정의할 때는 반드시 super.clone()을 호출해 얻은 객체를 반환해야 한다(clone을 오버라이드하는 클래스가 final인 경우는 subclass가 만들어질 수 없으므로 이 컨벤션은 무시될 수 있다). if a class that overrides clone is final, this convention may be safely ignored, as there are no subclasses to worry about.

Note : immutable 클래스는 불필요한 copy를 조장하기 때문에 clone 메서드를 제공하면 안된다.

만일 복제할 객체가 변경 가능 객체에 대한 참조 필드를 가지고 있다면, 위에서 본 clone을 그대로 이용하면 끔찍한 결과를 초래한다.

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

    public Stack() {
        this.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; // 만기 참조(obsolete reference)제거
        return result;
    }

    // 적어도 한 원소가들어갈 공간 확보
    private void ensureCapacity() {
        if (elements.length == size) {
            elements = Arrays.copyOf(elements, 2 * size + 1);
        }
    }
}

이 클래스를 복제 가능하도록 만들고 싶다고 하자. clone 메서드가 단순히 super.clone()이 반환한 객체를 그대로 반환하도록 구현한다면, 그 복사본의 size 필드는 올바른 값을 갖겠지만 elements 필드는 원래 Stack 객체와 같은 배열을 참조하게 된다. 그 상태에서 원래 객체나 복사본을 변경하면 다른 객체의 상태가 깨지게 된다.

사실상 clone 메서드는 또 다른 형태의 생성자다. 원래 객체를 손상시키는 일이 없도록 해야 하고, 복사본의 불변식(invariant)도 제대로 만족시켜야 한다.

Stack의 clone 메서드가 제대로 동작하도록 하려면 스택의 내부 구조도 복사해야 한다. 가장 간단한 방법은 elements 배열에도 clone을 재귀적으로 호출하는 것이다.

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

elements.clone() 호출 결과를 Object[]로 형변환 할 필요가 없음에 유의하자. 릴리스 1.5부터는 배열에 clone을 호출하면 반환되는 배열의 컴파일 시점 자료형은 복제 대상 배열의 자료형과 같다.

그런데 위의 해법은 elements 필드가 final로 선언되어 있으면 동작하지 않는다. clone 안에서 필드에 새로운 값을 할당할 수 없기 때문이다. 이것은 clone의 근본적 문제다. clone의 아키텍처는 변경 가능한 객체를 참조하는 final 필드의 일반적 용법과 호환되지 않는다. 복제 가능한 클래스를 만들려면 필드의 final 선언을 지워야 할 수도 있다.

그리고 clone을 재귀적으로 호출하는 것만으로 충분하지 않을 때도 있다. 예를 들어, 버킷 배열로 구성된 해시 테이블의 clone 메서드를 작성한다고 해보자. 각 버킷은 실제로는 키-값 쌍의 연결 리스트 첫 번째 원소에 대한 참조이며, 버킷이 빈 경우에는 null이다. 성능 문제 때문에 java.util.LinkedList를 사용하는 대신, 직접 구현한 경량의 단방향 연결 리스트를 사용한다고 하자.

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

    ...

이제 Stack에서 했던 대로 버킷 배열을 재귀적으로 복제한다고 해보자.

    // 잘못된 코드. 두 객체가 내부 상태를 공유하게 된다.
    @Override
    protected HashTable clone() {
        try {
            HashTable result = (HashTable)super.clone();
            result.buckets = buckets.clone();
            return result;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }

복사본이 자신만의 버킷 배열을 갖긴 하지만, 복제된 배열의 각 원소는 원래 배열 원소와 동일한 연결 리스트를 참조하게 된다. 그래서 쉽게 비결정적 행동을 유발하게 된다. 이 문제를 수정하려면 각 버킷을 구성하는 연결 리스트까지도 복사해야 한다.

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

        // 이 Entry 객체가 첫 원소인 연결 리스트를 재귀적으로 복사
        Entry deepCopy() {
            return new Entry(key, value, next == null ? null : next.deepCopy());
        }
    }

    @Override
    protected 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();
        }
    }

private 클래스 HashTable.Entry가 깊은 복사(deep copy)를 지원하도록 수정했다. HashTable의 clone 메서드는 적절한 크기의 새로운 buckets 배열을 할당하고 원래 buckets 배열을 돌면서 비어있지 않은 모든 버킷에 깊은 복사를 실행한다.

이 기법은 깔끔하고 버킷이 그리 길지 않다면 잘 동작한다. 하지만 연결 리스트를 복제하기에 좋은 방법이 아닌데, 리스트 원소마다 스택 프레임을 하나씩 사용하기 때문이다. 그러니 리스트가 길면 쉽게 스택 오버플로가 난다. 그런 상황을 방지하려면 재귀가 아니라 순환문을 사용해서 deepCopy를 구현해야 한다.

// 이 Entry 객체가 첫 원소인 연결 리스트를 순환문으로 복사
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;
}

복잡한 객체를 복제하는 마지막 방법은 super.clone 호출 결과로 반환된 객체의 모든 필드를 초기 상태로 되돌려 놓은 다음에, 상위 레벨 메서드를 호출해서 객체 상태를 다시 만드는 것이다. HashTable 예제의 경우, buckets 필드는 새로운 버킷 배열로 초기화될 것이고, 지면상 생략한 put(key, value) 메서드를 원래 헤시 테이블의 모든 키-값 쌍에 호출하면 복제가 완료된다. 이 방법은 간단하지만 원래 객체와 복사본의 내부 구조를 직접 제어하는 메서드만큼 빠르게 동작하지는 않는다.

while this approach is clean, it is antithetical to the whole cloneable architecture because it blindly overwrites the field-by-field object copy that forms the basis of the architecture. 이 접근 방법은 깨끗하지만 아키텍처의 기초를 형성하는 필드 별 객체 복사본을 맹목적으로 덮어 쓰므로 복제 가능 아키텍처 전체와는 정반대다.

생성자와 마찬가지로, clone 메서드는 복사본의 비-final 메서드, 즉 재정의 가능 메서드를 복사 도중에 호출해서는 안된다. 만일 하위 클래스에서 재정의한 메서드를 clone 안에서 호출하면, 해당 메서드는 복사본의 상태가 완성되기 전에 호출될 것이며, 원래 객체와 복사본의 상태를 망가뜨리게 될 것이다. 따라서 앞 단락에서 설명한 put(key, value)는 final이거나 private 메서드여야 한다(private 메서드라면, 아마 비-final public 메서드를 위한 help method일 것이다).

정리하자면 Cloneable을 구현하는 모든 클래스는 return type이 자기자신인 public clone 메서드를 재정의해야 한다. 그리고 맨 처음에 super.clone()을 호출해야 한다.

개체 복제를 지원하는 좋은 방법은, 복사 생성자(copy constructor)나 복사 팩토리(copy factory)를 제공하는 것이다. 복사 생성자는 단순히 같은 클래스의 객체 하나를 인자로 받는 생성자다.

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

복사 팩토리는 복사 생성자와 유사한 정적 팩토리 메서드다.

public static Yum newInstance(Yum yum);

이 접근법은 Cloneable/clone보다 좋은 점이 많다. 위험해 보이는 언어 외적(extralinguistic) 객체 생성 수단에 의존하지 않으며, 느슨한 규약에 충실할 것을 요구하지도 않고, final 필드 용법과 충돌하지 않으며, 불필요한 예외를 검사하도록 요구하지도 않고 형변환도 필요 없다. 인터페이스에 넣을 수 없다는 단점이 있지만, Cloneable도 인터페이스 구실을 못하기는 마찬가지다. clone을 public 메서드로 선언하지 않기 때문이다.

게다가 복사 생성자나 팩토리는 해당 메서드가 정의된 클래스가 구현하는 인터페이스를 인자로 받을 수 있다. conversion constructors와 conversion factories로 더 잘 알려져 있으며, 복사본 객체의 실제 구현 클래스를 클라이언트가 자유롭게 정할 수 있다. clone을 사용한다면 원래 객체와 동일한 클래스를 받아들일 수밖에 없다.

예를 들어 HashSet 형의 객체 s가 있고 이것을 TreeSet으로 복제하고 싶으면 new TreeSet(s)하면 된다.

    public TreeSet(Collection<? extends E> c) {
        this();
        addAll(c);
    }
    public ArrayList(Collection<? extends E> c) {
        elementData = c.toArray();
        if ((size = elementData.length) != 0) {
            // c.toArray might (incorrectly) not return Object[] (see 6260652)
            if (elementData.getClass() != Object[].class)
                elementData = Arrays.copyOf(elementData, size, Object[].class);
        } else {
            // replace with empty array.
            this.elementData = EMPTY_ELEMENTDATA;
        }
    }

규칙14 : Comparable 구현을 고려하라

equals와 달리 compareTo는 서로 다른 클래스 객체에는 적용될 필요가 없다.(하지만 equals도 계승을 통한 다른 객체들간의 비교는 규약을 깨야만한다.)

compareTo 규약을 준수하지 않는 클래스는 비교연산에 기반한 클래스들을 오동작시킬 수 있다. 이런 클래스로는 TressSet이나 TreeMap 같은 정렬된 컬렉션과, Arrays와 Collections같은 유틸리티 클래스들이 있다. 탐색과 정렬 알고리즘을 포함하는 클래스들이다.

저자의 강력한 권고사항 : (x.compareTo(y) == 0) == (x.equals(y)) 일반적으로, Comparable 인터페이스를 구현하면서 이 조건을 만족하지 않는 클래스는 반드시 그 사실을 명시해야 한다. 이렇게 적을 것을 추천한다. "주의: 이 클래스의 객체들은 equals에 부합하지 않는 자연적 순서를 따른다."

ex) BigDecimal 클래스 이 클래스의 compareTo 메서드는 equals에 부합하지 않는다. HashSet 객체를 만들어 거기에 new BigDecimal("1.0")과 new BigDecimal("1.00")로 만든 객체들을 추가해 보자. 그러면 집합에는 두 개의 객체가 추가된다. 이 두 객체를 equals로 비교하면 서로 다르다고 판정되기 때문이다. 하지만 HashSet 대신 TreeSet을 사용하면 집합에는 하나의 객체만 삽입된다. compareTo로 비교하면 그 두 객체는 같은 객체이기 때문이다.

Collection Comparable 퀴즈

public class ComparableTest {
    static class Item implements Comparable<Item> {
        private final int value;
        private final ZonedDateTime createdAt;
        private final int random = new Random().nextInt();

        public Item(int value) {
            this.value = value;
            this.createdAt = ZonedDateTime.now().truncatedTo(ChronoUnit.SECONDS);
        }

        @Override
        public int compareTo(Item o) {
            return this.createdAt.compareTo(o.createdAt);
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (o == null || getClass() != o.getClass()) {
                return false;
            }

            Item item = (Item) o;

            if (value != item.value) {
                return false;
            }
            return createdAt != null ? createdAt.equals(item.createdAt) : item.createdAt == null;
        }

        @Override
        public int hashCode() {
            int result = value;
            result = 31 * result + (createdAt != null ? createdAt.hashCode() : 0);
            return result;
        }

        @Override
        public String toString() {
            return "Item{" +
                "value=" + value +
                ", createdAt=" + createdAt +
                ", random=" + random +
                '}';
        }
    }



    public static void main(String... args) throws InterruptedException {
        Item item1 = new Item(1);
        Item item2 = new Item(1);
        Item item3 = new Item(2);

        Thread.sleep(1000);
        Item item4 = new Item(1);

        System.out.println("item1 equals item2: " + item1.equals(item2));    // true
        System.out.println("item2 equals item3: " + item2.equals(item3));    // false
        System.out.println("item1 equals item4: " + item1.equals(item4));    // false

        System.out.println("item1 compareTo item2: " + item1.compareTo(item2));   // 0
        System.out.println("item2 compareTo item3: " + item2.compareTo(item3));   // 0
        System.out.println("item3 compareTo item1: " + item3.compareTo(item1));   // 0
        System.out.println("item3 compareTo item4: " + item3.compareTo(item4));   // -1

        //  item1 item2  equals true
        //  item1 item2 item3  compare 0

        List<Item> arrayList = new ArrayList<>();
        arrayList.add(item1);
        arrayList.add(item2);
        arrayList.add(item3);
        arrayList.add(item4);
        System.out.println("arrayList size: " + arrayList.size());     // 4
        arrayList.forEach(i -> System.out.println("arrayList item value: " + i.value));    //  item1, item2, item3, item4

        Set<Item> linkedHashSet = new LinkedHashSet<>();
        System.out.println("item1 " + item1);
        System.out.println("item2 " + item2);
        linkedHashSet.add(item1);
        linkedHashSet.add(item2);
        linkedHashSet.add(item3);
        linkedHashSet.add(item4);
        System.out.println("linkedHashSet size: " + linkedHashSet.size());     // 3
        linkedHashSet.forEach(i -> System.out.println("linkedHashSet item value: " + i.value + i));     //  item1, item3, item4

        Set<Item> treeSet = new TreeSet<>();
        treeSet.add(item1);
        treeSet.add(item2);
        treeSet.add(item3);
        treeSet.add(item4);
        System.out.println("treeSet size: " + treeSet.size());    // 2
        treeSet.forEach(i -> System.out.println("treeSet item value: " + i));    // item1, item4


        Map<Item, String> linkedHashMap = new LinkedHashMap();
        linkedHashMap.put(item1, "item1");
        linkedHashMap.put(item3, "item3");
        linkedHashMap.put(item4, "item4");
        linkedHashMap.put(item2, "item2");
        System.out.println("linkedHashMap size: " + linkedHashMap.size());    // 3
        linkedHashMap.forEach((entry, value) -> System.out.println("linkedHashMap value: " + value));    // item2, item3, item4

        Map<Item, String> treeMap = new TreeMap<>();
        treeMap.put(item1, "item1");
        treeMap.put(item2, "item2");
        treeMap.put(item3, "item3");
        treeMap.put(item4, "item4");
        System.out.println("treeMap size: " + treeMap.size());    // 2
        treeMap.forEach((entry, value) -> System.out.println("treeMap value: " + value));     // item3, item4
    }
}

Item 클래스에서 equals는 value 변수와 객체 생성 시간인 createdAt(초단위)이 모두 일치하면 true로 재정의했다. compareTo는 생성 시간만 일치하도록 재정의했다.

Item item1 = new Item(1);
Item item2 = new Item(1);
Item item3 = new Item(2);

Thread.sleep(1000);
Item item4 = new Item(1);

그랬을 때 item1, item2는 equals가 true이고 나머지는 다 false로, 즉 다르다는 결과가 나온다(item3은 value가 달라서이고 item4는 createdAt이 달라서).

List<Item> arrayList = new ArrayList<>();
arrayList.add(item1);
arrayList.add(item2);
arrayList.add(item3);
arrayList.add(item4);
System.out.println("arrayList size: " + arrayList.size());     // 4
arrayList.forEach(i -> System.out.println("arrayList item value: " + i.value));    //  item1, item2, item3, item4

ArrayList에는 모두 추가되서 size가 4다.

Set<Item> linkedHashSet = new LinkedHashSet<>();
linkedHashSet.add(item1);
linkedHashSet.add(item2);
linkedHashSet.add(item3);
linkedHashSet.add(item4);
System.out.println("linkedHashSet size: " + linkedHashSet.size());     // 3
linkedHashSet.forEach(i -> System.out.println("linkedHashSet item value: " + i.value + i));     //  item1, item3, item4

linkedHashSet은 size가 3인데 add 할 때 equals로 비교하기 때문이다.

Set<Item> treeSet = new TreeSet<>();
treeSet.add(item1);
treeSet.add(item2);
treeSet.add(item3);
treeSet.add(item4);
System.out.println("treeSet size: " + treeSet.size());    // 2
treeSet.forEach(i -> System.out.println("treeSet item value: " + i));    // item1, item4

treeSet은 size가 2인데 add 할 때 compare로 비교하기 때문이다.

Map<Item, String> linkedHashMap = new LinkedHashMap();
linkedHashMap.put(item1, "item1");
linkedHashMap.put(item3, "item3");
linkedHashMap.put(item4, "item4");
linkedHashMap.put(item2, "item2");
System.out.println("linkedHashMap size: " + linkedHashMap.size());    // 3
linkedHashMap.forEach((entry, value) -> System.out.println("linkedHashMap value: " + value));    // item2, item3, item4

linkedHashMap은 linkedHashSet과 같이 size가 3이지만, item1을 item2가 덮어쓴다는 차이점이 있다.

Map<Item, String> treeMap = new TreeMap<>();
treeMap.put(item1, "item1");
treeMap.put(item2, "item2");
treeMap.put(item3, "item3");
treeMap.put(item4, "item4");
System.out.println("treeMap size: " + treeMap.size());    // 2
treeMap.forEach((entry, value) -> System.out.println("treeMap value: " + value));     // item3, item4

treeMap은 treeSet과 마찬가지로 compare로 비교해 size가 2이지만, 같은것은 덮어써져 최종적으로는 item3, item4가 담겨있다.

Last updated