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를 반환한다. 두 객체에게 서로 같은지 물으면 같은 답이 나와야 한다는 것이다.
//대칭성 위반 클래스!!publicfinalclassCaseInsensitiveString{privatefinalString s;publicCaseInsensitiveString(String s){if( s ==null)thrownewNullPointerException();this.s= s; }//대칭성 위반 !! @Overridepublicbooleanequals(Object o){if(o instanceof CaseInsensitiveString){returns.equalsIgnoreCase(((CaseInsensitiveString)o).s); }if(o instanceof String){ //한 방향으로만 정상 동작! returns.equalsIgnoreCase((String)o); }returnfalse; }}
CaseInsensitiveString cis =newCaseInsensitiveString("Polish");String s ="polish";
cis.equals(s)는 True를 반환할 것이다. 하지만 s.equals(cis)는 false를 반환한다. 왜냐하면 String은 CaseInsensitiveString이 뭔지 모르기 때문이다.
그러므로 이 문제를 방지하려면 CaseInsensitiveString의 equals 메서드가 String 객체와 상호작용하지 않도록 해야 한다.
@Overridepublicbooleanequals(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가 비교할 새로운 정보를 추가한다는 뜻이다.
publicclassPoint{privatefinalint x;privatefinalint y;publicPoint(int x,int y){this.x= x;this.y= y; } @Overridepublicbooleanequals(Object o){if(!(o instanceof Point)){returnfalse; }Point p = (Point)o;returnp.x== x &&p.y==y; }}
p1.equals(p2)와 p2.equals(p3)는 모두 true를 반환하지만 p1.equals(p3)는 false를 반환한다.
이 문제의 해결책은 무엇인가? 사실 이것은 객체 지향 언어에서 동치 관계를 구현할 때 발생하는 본질적 문제다. 객체 생성가능 클래스를 계승하여 새로운 값 컴포넌트를 추가하면서 equals 규악을 어기지 않을 방법은 없다.
equals 메서드를 구현할 때 instanceof 대신 getClass 메서드를 사용하면 기존 클래스를 확장하여 새로운 값 컴포넌트를 추가하더라도 equals 규약을 준수할 수 있다?
//리스코프 대체 원칙 위반@Overridepublicbooleanequals(Object o){if(o ==null||o.getClass() !=getClass())returnfalse;Point p = (Point)o;returnp.x== x &&p.y== y;}
이렇게 하면 같은 클래스의 객체만 비교하게 된다. 하지만 아래의 예제를 보면 올바르지 않다는 것을 알 수 있다.
//단위 원 상의 모든 점을 포함하도록 unitCircle 초기화 privatestaticfinalSet<Point> uniCircle;static{ unitCircle =newHashSet<Point>();unitCircle.add(newPoint(1,0));unitCircle.add(newPoint(0,1));unitCircle.add(newPoint(-1,0));unitCircle.add(newPoint(0,-1));}publicstaticbooleanonUnitCircle(Point p){returnunitCircle.contatins(p); }
리스코프 대체 원칙은 어떤 자료형의 중요한 속성은 하위 자료형에도 그대로 유지되어서, 그 자료형을 위한 메서드는 하위 자료형에도 잘 동작해야 한다는 원칙이다. 그런데 CounterPoint 객체를 onUnitCircle 메서드의 인자로 넘기는 경우를 생각해보자. Point 클래스의 equals 메서드가 getClass를 사용하고 있다면, onUnitCircle 메서드는 CounterPoint 객체의 x나 y값에 상관없이 무조건 false를 반환할 것이다.
이는 onUnitCircle 메서드가 이용하는 HashSet 같은 컬렉션이 객체 포함여부를 판단할 때 equals를 사용하기 때문이며, CounterPoint객체는 어떤 Point객체와도 같을 수 없기 때문이다.
객체 생성 가능 클래스를 계승해서 새로운 값 컴포넌트를 추가할 만족스러운 방법이 없긴 하지만, 문제를 깔끔하게 피할 수 있는 방법은 하나 있다. Point를 계승해서 ColorPoint를 만드는 대신, ColorPoint안에 private Point 필드를 두고, public 뷰(view) 메서드를 하나 만드는 것이다. 이 뷰 메서드는 ColorPoint가 가리키는 위치를 Point 객체로 반환한다.
자바의 기본 라이브러리 가운데는 객체 생성 가능 클래스를 계승하여 값 컴포넌트를 추가한 클래스도 있다. 일례로 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인지 검사할 필요없다.
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 멤버가 어떻게 동작할지 규정하는 용도로 쓰이고 있다.
cf) native 키워드는 자바가 아닌 언어(보통 C나 C++)로 구현한 후 자바에서 사용하려고 할때 이용하는 키워드이다. 자바로 구현하기 까다로운 것을 다른 언어로 구현해서, 자바에서 사용하기 위한 방법이다.
릴리스 1.6에서도 Cloneable은 해당 인터페이스를 구현하는 클래스가 어떤 책임을 져야 하는지 상세히 밝히지 않는다. 실질적으로 Cloneable 인터페이스를 구현하는 클래스는 제대로 동작하는 public clone 메서드를 제공해야 한다.
//Clone 사용 예시 만들어봤다. publicclassCloneTestimplementsCloneable{privatefinalint a;privatefinalint b;privatefinalint c =100;publicCloneTest(){ a =1; b =2; } @OverridepublicCloneTestclone() {try {return (CloneTest) super.clone(); } catch(CloneNotSupportedException e) {e.printStackTrace(); }returnnull; }//setter getter ...}
위의 clone 메서드는 Object가 아니라 CloneTest를 반환한다. 1.5버전부터는 이렇게 할 수 있을 뿐 아니라, 그렇게 하는 것이 바람직하다. 제네릭의 일부로 공변 반환형(covariant return type)이 도입되었기 때문이다. 다시 말해서, 재정의 메서드의 반환값 자료형은 재정의 되는 메서드의 반환값 자료형의 하위 클래스가 될 수 있다. 덕분에 재정의 메서드는 반환될 객체에 대한 더 많은 정보를 제공 할 수 있고, 클라이언트는 형변환을 하지 않아도 된다. 여기서 강조하고 싶은 일반 원칙 하나는, 라이브러리가 할 수 있는 일을 클라이언트에게 미루지 말라는 것이다.
추가적으로 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.
만일 복제할 객체가 변경 가능 객체에 대한 참조 필드를 가지고 있다면, 위에서 본 clone을 그대로 이용하면 끔찍한 결과를 초래한다.
publicclassStack {privateObject[] elements;privateint size =0;privatestaticfinalint DEFAULT_INITIAL_CAPACITY =16;publicStack() {this.elements=newObject[DEFAULT_INITIAL_CAPACITY]; }publicvoidpush(Object e) {ensureCapacity(); elements[size++] = e; }publicObjectpop() {if (size ==0)thrownewEmptyStackException();Object result = elements[--size]; elements[size] =null; // 만기 참조(obsolete reference)제거return result; }// 적어도 한 원소가들어갈 공간 확보privatevoidensureCapacity() {if (elements.length== size) { elements =Arrays.copyOf(elements,2* size +1); } }}
이 클래스를 복제 가능하도록 만들고 싶다고 하자. clone 메서드가 단순히 super.clone()이 반환한 객체를 그대로 반환하도록 구현한다면, 그 복사본의 size 필드는 올바른 값을 갖겠지만 elements 필드는 원래 Stack 객체와 같은 배열을 참조하게 된다. 그 상태에서 원래 객체나 복사본을 변경하면 다른 객체의 상태가 깨지게 된다.
사실상 clone 메서드는 또 다른 형태의 생성자다. 원래 객체를 손상시키는 일이 없도록 해야 하고, 복사본의 불변식(invariant)도 제대로 만족시켜야 한다.
Stack의 clone 메서드가 제대로 동작하도록 하려면 스택의 내부 구조도 복사해야 한다. 가장 간단한 방법은 elements 배열에도 clone을 재귀적으로 호출하는 것이다.
elements.clone() 호출 결과를 Object[]로 형변환 할 필요가 없음에 유의하자. 릴리스 1.5부터는 배열에 clone을 호출하면 반환되는 배열의 컴파일 시점 자료형은 복제 대상 배열의 자료형과 같다.
그런데 위의 해법은 elements 필드가 final로 선언되어 있으면 동작하지 않는다. clone 안에서 필드에 새로운 값을 할당할 수 없기 때문이다. 이것은 clone의 근본적 문제다. clone의 아키텍처는 변경 가능한 객체를 참조하는 final 필드의 일반적 용법과 호환되지 않는다. 복제 가능한 클래스를 만들려면 필드의 final 선언을 지워야 할 수도 있다.
그리고 clone을 재귀적으로 호출하는 것만으로 충분하지 않을 때도 있다. 예를 들어, 버킷 배열로 구성된 해시 테이블의 clone 메서드를 작성한다고 해보자. 각 버킷은 실제로는 키-값 쌍의 연결 리스트 첫 번째 원소에 대한 참조이며, 버킷이 빈 경우에는 null이다. 성능 문제 때문에 java.util.LinkedList를 사용하는 대신, 직접 구현한 경량의 단방향 연결 리스트를 사용한다고 하자.
// 잘못된 코드. 두 객체가 내부 상태를 공유하게 된다. @OverrideprotectedHashTableclone() {try {HashTable result = (HashTable)super.clone();result.buckets=buckets.clone();return result; } catch (CloneNotSupportedException e) {thrownewAssertionError(); } }
복사본이 자신만의 버킷 배열을 갖긴 하지만, 복제된 배열의 각 원소는 원래 배열 원소와 동일한 연결 리스트를 참조하게 된다. 그래서 쉽게 비결정적 행동을 유발하게 된다. 이 문제를 수정하려면 각 버킷을 구성하는 연결 리스트까지도 복사해야 한다.
publicclassHashTable {privateEntry[] buckets =...;privatestaticclassEntry {finalObject key;Object value;Entry next;Entry(Object key,Object value,Entry next) {this.key= key;this.value= value;this.next= next; } }// 이 Entry 객체가 첫 원소인 연결 리스트를 재귀적으로 복사EntrydeepCopy() {returnnewEntry(key, value, next ==null?null:next.deepCopy()); } } @OverrideprotectedHashTableclone() {try {HashTable result = (HashTable)super.clone();result.buckets=newEntry[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) {thrownewAssertionError(); } }
private 클래스 HashTable.Entry가 깊은 복사(deep copy)를 지원하도록 수정했다. HashTable의 clone 메서드는 적절한 크기의 새로운 buckets 배열을 할당하고 원래 buckets 배열을 돌면서 비어있지 않은 모든 버킷에 깊은 복사를 실행한다.
이 기법은 깔끔하고 버킷이 그리 길지 않다면 잘 동작한다. 하지만 연결 리스트를 복제하기에 좋은 방법이 아닌데, 리스트 원소마다 스택 프레임을 하나씩 사용하기 때문이다. 그러니 리스트가 길면 쉽게 스택 오버플로가 난다. 그런 상황을 방지하려면 재귀가 아니라 순환문을 사용해서 deepCopy를 구현해야 한다.
// 이 Entry 객체가 첫 원소인 연결 리스트를 순환문으로 복사EntrydeepCopy() {Entry result =newEntry(key, value, next);for (Entry p = result; p.next!=null; p =p.next)p.next=newEntry(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)를 제공하는 것이다. 복사 생성자는 단순히 같은 클래스의 객체 하나를 인자로 받는 생성자다.
publicYum(Yum yum) { ... };
복사 팩토리는 복사 생성자와 유사한 정적 팩토리 메서드다.
publicstaticYumnewInstance(Yum yum);
이 접근법은 Cloneable/clone보다 좋은 점이 많다. 위험해 보이는 언어 외적(extralinguistic) 객체 생성 수단에 의존하지 않으며, 느슨한 규약에 충실할 것을 요구하지도 않고, final 필드 용법과 충돌하지 않으며, 불필요한 예외를 검사하도록 요구하지도 않고 형변환도 필요 없다. 인터페이스에 넣을 수 없다는 단점이 있지만, Cloneable도 인터페이스 구실을 못하기는 마찬가지다. clone을 public 메서드로 선언하지 않기 때문이다.
게다가 복사 생성자나 팩토리는 해당 메서드가 정의된 클래스가 구현하는 인터페이스를 인자로 받을 수 있다. conversion constructors와 conversion factories로 더 잘 알려져 있으며, 복사본 객체의 실제 구현 클래스를 클라이언트가 자유롭게 정할 수 있다. clone을 사용한다면 원래 객체와 동일한 클래스를 받아들일 수밖에 없다.
예를 들어 HashSet 형의 객체 s가 있고 이것을 TreeSet으로 복제하고 싶으면 new TreeSet(s)하면 된다.
publicTreeSet(Collection<? extends E> c) {this();addAll(c); }
publicArrayList(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로 비교하면 그 두 객체는 같은 객체이기 때문이다.