목차
이번 챕터의 목적
모든 객체의 조상 객체는 Object클래스다.
이 Object에서 final이 아닌 메서드(equals, hashCode, toString, clone, finalize)는 오버라이딩을 염두해두고 설계된 메서드들로, 재정의를 일반 규약에 맞지 않게 구현하면 의도와 다르게 동작할 수 있다.
이번 챕터에서는 이런 메서드들을 언제 어떻게 재정의해야하는지를 다룬다.
10. equals는 일반 규약을 지켜 재정의하라
두 객체가 같은지 비교하는 목적으로 사용되는 equals 메서드는 언뜻 보면 재정의하기도 쉬워보인다. 하지만, 몇가지 규약들을 제대로 지키지 않을경우 의도와 다르게 동작을해서 프로그램에 오류를 발생시킬 수 있다.
그렇기에 가장 쉬운 해결책은 재정의를 하지 않고 Object에 정의된 equals 로직을 그대로 사용하는 것이다.
재정의를 하지 않게 될 경우 해당 클래스의 인스턴스는 자기자신과만 같다고 평가하게 된다.
두 개의 Person 객체 인스턴스
위 두 개의 객체 인스턴스를 살펴보면 둘 다 내용(name, age)은 catsbi/34로 동일하다. 하지만, 재정의하지 않은 Object의 equals을 사용하면 둘은 다른 객체로 평가된다. 즉, 동등성은 성립하지만 동일성은 성립하지 않기 때문인데, 그래서 equals를 재정의해야 하는 경우들이 있는다.
재정의 하지 않아도 되는 경우
•
각 인스턴스가 본질적으로 고유한 경우
⇒ 값이 아닌 동작 개체 인스턴스는 동일한 인스턴스가 애초에 없기에(Ex: Thread) Object의 equals로 충분하다.
•
인스턴스의 논리적 동치성(logical equality)을 검사할 일이 없다.
⇒ 값을 비교해서 동등한지 비교할 일이 없다면 논리적 동치성 검사를 할 일도 없다는 것이고, 기본적인 Object의 equals로 충분하다.
•
상위 클래스에서 재정의한 equals가 하위 클래스에서 들어맞는다.
⇒ 상위에서 구현한 equals로직으로 충분한 경우 재정의할게아니라 상위 클래스에 정의된 equals를 사용한다. (Ex: Set의 구현체는 AbstractSet이 구현한 equals를 상속받아 사용한다)
•
클래스가 private이거나 package-private이고 equals를 사용할 일이 없다.
⇒이 경우 만약 equals가 실수로라도 호출되는 걸 막고싶다면 다음과같이 재정의하면 된다.
@Override public boolean equals(Object o) {
throw new AssertionError(); // 호출 금지!
}
Java
복사
재정의가 필요한 경우
•
객체 식별성(object identity)이 아니라 논리적 동치성(logical equality)이 필요한 경우
⇒ 더하여, 상위 클래스가 있는경우 상위 클래스에서 논리적 동치성을 비교하도록 재정의 하지 않았을 경우
⇒ Ex: 값 클래스(Integer, String...)
⇒ 값 클래스(Value Object)여도 값이 여러개 만들어지지 않는 통제 클래스일 경우는 제외된다.
equals 메서드 재정의 규약
다음은 Object 명세에 적힌 equals 메서드 재정의 규약이다.
컬렉션 클래스를 포함한 대부분의 클래스는 equals가 규약을 지킨다는 것을 가정하고 동작하기에 중요하다.
•
반사성(reflextivity)
⇒ null이 아닌 모든 참조 값 x에 대해 x.equals(x)는 true여야 한다.
•
대칭성(symmetry)
⇒ null이 아닌 모든 참조 값 x, y에 대해 x.equals(y)와 y.equals(x)의 결과는 같아야 한다.
•
추이성(transitivity)
⇒ null이 아닌 모든 참조 값 x, y, z에 대해 x.equals(y)와 y.equals(z)가 true면 x.equals(z)도 true이다.
⇒ x와 y가 같고 y와 z가 같으면 x 와 z는 같아야 한다.
•
일관성(constistency)
⇒ null이 아닌 모든 참조 값 x, y에 대해 x.equals(y)를 반복해서 호출하면 항상 같은 값을 반환해야 한다.
•
null-아님
⇒ null이 아닌 모든 참조 값 x에 대해서, x.equals(null)은 false여야 한다.
재정의 규약 상세
1. 반사성(reflextivity)
객체는 자기자신과 같아야 한다는 뜻으로 "catsbi".equals("catsbi") 라는 코드는 항상 true를 반환해야 한다. 이 조건은 만족하지 못하도록 하는게 더 힘들 것 같다.
2. 대칭성(symmetry)
두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다.
이 규약은 내가 로직을 어떻게 짜느냐에 따라서 위반되기 쉬운 규약이다. 다음 코드를 보자.
public final class CaseInsensitiveString {
private final String str;
public CaseInsensitiveString(String str) {
this.str = Objects.requireNonNull(str);
}
//대칭성 위배
@Override
public boolean equals(Object o) {
if (o instanceof CaseInsensitiveString){
return str.equalsIgnoreCase(((CaseInsensitiveString)o).str);
}
if (o instanceof String) {
return str.equalsIgnoreCase((String) o);
}
return false;
}
}
Java
복사
CaseInsensitiveString cis = new CaseInsensitiveString("Catsbi");
String str = "Catsbi";
Java
복사
위 코드는 대칭성에 위배된 코드이다.
cis.equals(str)은 CaseInsensitiveString 클래스 내부의 equals를 보면 해당 클래스가 아닌 String 클래스일 경우도 고려해서 로직을 작성했다. 그렇기에 true가 반환될 것이다. 하지만, 반대로 str.equals(cis)를 할 경우 반환 값은 false이다. 이러한 결과가 나오는 이유는 CaseInsensitiveString 클래스에서는 String을 알고 대응하지만, String 클래스에서는 CaseInsensitiveString의 존재를 모르기 때문이다.
더하여, CaseInsensitiveString클래스가 대칭성을 위반했기 때문에 콜렉션 클래스에서 제대로 동작도 하지 않는다. 다음 코드를보자.
List<CaseInsensitiveString> list = new ArrayList<>();
list.add(cis);
Java
복사
이렇게 콜렉션 클래스에 list.contains(str)을 호출하면 어떤 결과가 나올까? CaseInsensitiveString 클래스에서는 String도 대응하게 만들었으니 true가 반환될까? 이는 JDK 버전에 따라 true일 수도 false일 수도 혹은 Exception이 발생할 수도 있다. 중요한점은 규약을 어겼을 경우 해당 객체를 사용하는 다른 객체들이 어떻게 반응할지 알 수 없다는 점이다.
3. 추이성(transitivity)
수학에는 추이적 관계라는게 있다.
이를 프로그래밍으로 살펴보면 첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면, 첫 번째 객체와 세 번째 객체도 같아야 한다는 의미가 된다.
이 규약은 동일한 객체간에서는 규약을 지키는게 어렵지 않지만, 새로운 필드 확장이 필요한 상황에서 만들어진 상속클래스들이 생기면 규약을 어길 수 있다.
가령 위치를 표현하는 점 클래스인 Point가 있고, 해당 객체에 색을 추가한 ColorPoint가 있다고 하면 이 두 객체간에는 추이성을 위반할 가능성이 생긴다.
@Data
public class Point {
private final long x ;
private final long y;
public Point(long x, long y) {
this.x = x;
this.y = y;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof Point)) return false;
Point point = (Point) o;
return x == point.x && y == point.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
}
Java
복사
@Getter
public class ColorPoint extends Point{
private Color color;
public ColorPoint(long x, long y, Color color) {
super(x, y);
this.color = color;
}
}
Java
복사
지금 이 상태에서 ColorPoint와 Point객체간에 비교를 하면 대칭성과 반사성은 성립된다. 하지만 색이 다른경우를 비교할 수 없다. 그렇기에 다음 코드는 true를 반환하는데 이는 잘못된 결과다.
Point point = new Point(0,1);
ColorPoint bluePoint = new ColorPoint(0, 1, Color.BLUE);
System.out.println(point.equals(bluePoint)); //true
System.out.println(bluePoint.equals(point)); //true
Java
복사
그렇다고, 하위 클래스인 ColorPoint에 색을 비교하는 코드를 넣는다면 대칭성에 위배가 된다.
다음 코드는 Point객체의 equals에서는 색상비교를 무시하게 되고 ColorPoint객체에서는 Point객체는 받지 않기 때문에 항상 false를 반환할 것이다.
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint)) return false;
ColorPoint that = (ColorPoint) o;
return super.equals(o) && that.color == color;
}
Java
복사
그렇다면, 여기서 Point객체일때는 색상을 무시하고 ColorPoint일때는 색상까지 비교하도록 로직을 짜면 어떨까?
@Override
public boolean equals(Object o) {
if (!(o instanceof Point)) return false;
if (!(o instanceof ColorPoint)) return o.equals(this);
return super.equals(o) && ((ColorPoint) o).color == color;
}
Java
복사
이렇게 로직을 구현하면 Point와 ColorPoint간에 대칭성이 유지된다. 그럼 해결된 것일까?
결론부터 말하자면, 아니다. 대칭성은 지켜지지만 아직도 추이성은 깨진다. 위와 같이 equals를 재정의 했다고 하면 다음 코드는 우리의 생각과는 다르게 동작한다.
Point point = new Point(0,1);
ColorPoint bluePoint = new ColorPoint(0, 1, Color.BLUE);
ColorPoint redPoint = new ColorPoint(0, 1, Color.RED);
System.out.println("bluePoint.equals(point) = " + bluePoint.equals(point));//true
System.out.println("point.equals(redPoint) = " + point.equals(redPoint));//true
System.out.println("bluePoint.equals(redPoint) = " + bluePoint.equals(redPoint));//false
Java
복사
추이성이 지켜졌다면 셋 다 true가 발생해야 하지만, 결과는 bluePoint.equals(redPoint)는 false를 반환한다.
이는 bluePoint와 redPoint비교시에는 색상까지 비교를 했기 때문인데, 이처럼 확장 클래스에서 추가된 필드를 포함하면서 equals 규약을 만족시키는 것은 불가능하다.
그럼, 하위 클래스에서 값을 추가하고 비교할 수는 없을까? 괜찮은 우회법으로 상속대신 컴포지션을 사용하는 것이 있다. Point를 상속하는 대신 Point객체를 필드로 만든 뒤 Point 뷰 메서드를 만드는 식이다.
public class ColorPointFromComposition {
private final Point point;
private final Color color;
public ColorPointFromComposition(int x, int y, Color color) {
this.point = new Point(x, y);
this.color = color;
}
public Point asPoint() {
return point;
}
@Override
public boolean equals(Object o) {
if(!(o instanceof ColorPointFromComposition))return false;
ColorPointFromComposition cp = (ColorPointFromComposition) o;
return cp.point.equals(point) && cp.color.equals(color);
}
}
Java
복사
참고
추상 클래스의 하위 클래스에서는 equals 규약을 지키면서 값도 추가 가능하다.
아무런 값을 가지지 않는 추상 클래스를 두고 이를 확장한 클래스를 만들면 가능하다. 즉, 상위 클래스가 직접 인스턴스로 만드는게 불가능하다면 위와같은 문제들이 발생하지 않는다.
4. 일관성(constistency)
두 객체가 같다면 영원히 같아야 한다. 시간에따라 결과가 달라지고 호출하는 횟수에따라 결과가 달라지면 안된다.
특히나, 불변 클래스는 equals의 결과는 항상 동일해야 한다. 그리고 equals의 반환 결과에 신뢰할 수 없는 자원이 들어가선 안된다. 예를들면, java.net.URL이 있는데 여기서 equals는 URL과 매핑된 호스트의 IP 주소를 이용해 비교하는데, 호스트 이름을 IP주소로 바꾸려면 네트워크를 통해야 하고 이 결과가 항상 동일하지는 않는다.
이런 문제를 피하기 위해서는 equals는 항시 메모리에 존재하는 객체마내을 사용한 결정적(deterministic)계산만 수행해야 한다.
5. null-아님
모든 객체는 null과 같지 않아야한다. 즉 어떤 객체든 객채.equals(null)은 false를 반환해야 한다.
여기서 중요한점은 true만 반환하지 않으면 된다는게 아니라 NPE도 발생시켜서는 안된다는 것이다.
하지만, 그렇다고 if(o == null){ ... } 이런식으로 null check를 해 줄 필요는 없으며 instanceof
연산자를 사용하면 null인 경우 false를 반환하기 때문에 굳이 따로 null check를 해 줄 필요는 없다.
정리
양질의 equals 메서드를 만들기 위해서는 위의 규약들을 잘 지키고 필요한경우세 재정의를 해줘야 한다.
그러기위해 다음 과정들을 구현해서 사용하는게 좋다.
1.
==연산자를 사용해 입력이 자기 자신의 참조인지(반사성) 확인한다.
2.
instanceof 연산자로 입력이 올바른 타입인지 확인한다.
3.
입력을 올바른 타입으로 형변환한다.
4.
입력 객체와 자기 자신의 대응되는 핵심필드들이 모두 일치하는지 하나씩 검사한다.
5.
equals를 재정의할 땐 hashCode도 반드시 재정의하자.
11. equals를 재정의하려거든 hashCode도 재정의하라.
why?
: hashCode 일반 규약을 어기게 되어 해당 클래스의 인스턴스를 HashMap이나 HashSet같은 컬렉션의 원소로 사용할 때 문제를 일으킨다.
HashCode 일반규약
: Object 명세에서 발췌한 HashCode 규약이다.
•
애플리케이션이 유지되는 동안 equals비교에 사용되는 정보(핵심필드)가 유지된다면, hashCode의 멱등성은 보장되야 한다.
•
equals(Object)가 동일하다고 판단되면 두 객체의 hashCode는 동일한 값을 반환해야 한다.
•
equals(Object)가 동일하지 않다고 판단하더라도 hashCode가 서로 다른 값을 반환할 필요는 없다.
하지만, 다른 값을 반환해야 해시테이블의 성능이 좋아진다.
문제발생 사례
equals(Object)메서드는 물리적으로는 다른 객체지지만, 논리적 동치성은 성립할 경우 같다고 재정의할 수 있다.
그런데 이 경우, hashCode가 재정의되지 않는다면, Object의 기본 hashCode가 수행되는데 해당 메서드에서는 논리적으로 같다고 해도 물리적으로 다르다고 판단되면 서로 다른 값을 반환한다.
예시 코드
public class PhoneNumber {
private String prefix;
private String middle;
private String suffix;
...
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof PhoneNumber)) return false;
PhoneNumber that = (PhoneNumber) o;
return Objects.equals(prefix, that.prefix)
&& Objects.equals(middle, that.middle)
&& Objects.equals(suffix, that.suffix);
}
}
public class PhoneNumberApplication {
public static void main(String[] args) {
PhoneNumber phoneNumber1 = new PhoneNumber("010", "1234", "5678");
PhoneNumber phoneNumber2 = new PhoneNumber("010", "1234", "5678");
System.out.println("phoneNumber1.equals(phoneNumber2) = " +
phoneNumber1.equals(phoneNumber2));
Map<PhoneNumber, String> map = new HashMap<>();
map.put(phoneNumber1, "catsbi");
System.out.println(map.get(phoneNumber1)); //catsbi
System.out.println(map.get(phoneNumber2)); //null
}
}
Java
복사
실행해보면 equals는 재정의를 해줬기 때문에 equals(Object)의 결과는 true이다. 즉 논리적으로 볼 때 phoneNumber1과 phoneNumber2는 동일하다는 의미다.
하지만 HashMap에서 key값으로 phoneNumber1이아닌 phoneNumber2를 사용할 경우 null을 반환한다. 이유는 PhoneNumber 클래스는 hashCode 메서드를 재정의 해주지 않았기 때문에 논리적으로 동일하더라도 해시코드는 서로 다르게 반환되기에 두 번째 규약을 지키지 못한다.
대안책
문제는 없지만 사용해서는 안되는 방법
@override public int hashCode() { return 42; }
Java
복사
위와같이 hashCode를 재정의한다면 같은 객체는 모두 같은 해시코드를 반환한다. 그래서 위의 예제에서 map.get(phoneNumber2)를 하더라도 값이 나올 것이다. 하지만, 모든 객체가 같은 해시코드를 반환하기에 모든 객체가 해시테이블의 버킷 하나에 담기기 때문에 마치 연결 리스트 처럼 동작한다.
그 결과 평균 수행 시간이 O(n)으로 느려져서 객체가 많아지면 쓸 수 없게 된다.
좋은 hashCode 작성법
이상적인 해시 함수는 주어진 인스턴스들을 32비트 정수 범위에 균일하게 분배해야 한다.
1.
지역변수 선언 후 핵심필드 값 하나의 해시코드로 초기화
•
기본 타입 필드라면 Type.hashCode(f)를 수행한다.(Type: Wrapper Class)
•
참조 타입 필드라면 이 필드의 hashCode를 재귀적으로 호출한다. 계산이 많이 복잡해질 것 같으면 필드의 표준형(canonical representation)을 만들어 표준형의 hashCode를 호출한다. 필드의 값이 null이면 0을 사용한다.
•
배열이라면 핵심 원소 각각을 별도 필드처럼 다룬다. 만약 모든 원소가 핵심 원소라면 Arrays.hashCode를 사용한다.
2.
다른 핵심필드들도 동일하게 해시코드화하여 지역변수에 합친다.
•
지역변수 = 31 * 지역변수 + 핵심필드의 해시코드
3.
지역변수의 값을 반환한다.
@Override public int hashCode() {
int result = prefix.hashCode();
result = 31 * result + middle.hashCode();
result = 31 * result + suffix.hashCode();
return result;
}
Java
복사
전형적인 hashCode 메서드
참고: 지역변수 = 31 * 지역변수 + 핵심필드의 해시코드
곱할 숫자가 31인 이유는 31이 홀수이면서 소수(prime)이기 때문이다.
만약, 짝수이고 오버플로가 발생한다면 정보를 잃게된다. (2를 곱하는건 시프트 연산과 같기 때문이다.)
그리고 소수를 곱하는 이유는 전통적으로 그래왔다고 한다.(명확하지는 않다.)
그리고 31 * i는 (i << 5) -1 과 동일하다.
Objects 클래스의 hash 메서드 사용
Objects 클래스는 임의의 갯수만큼 객체를 받아 해시코드를 계산해주는 정적메서드 hash를 제공한다.
장점
•
위와 비슷한 수준의 hashCode를 한 줄로 작성가능하다.
단점
•
속도가 더 느리다.
⇒ 입력 인수를 담기위한 배열을 만들고 기본 타입은 박싱/언박싱도 거쳐야 하기 때문이다.
@Override public int hashCode() {
return Objects.hash(prefix, middle, suffix);
}
Java
복사
고려사항
객체가 해시의 키로 사용될 확률이 높다면 객체 생성시 해시코드를 계산해서 캐싱해두는게 좋다. 하지만, 그렇지 않은 경우 hashCode를 미리 계산해놓고 캐싱까지 해놓는것은 비용낭비다.
이럴 경우 지연 초기화(lazy initialization)전략을 고려해볼만하다.
public class PhoneNumber {
private int hashCode;
private String prefix;
private String middle;
private String suffix;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof PhoneNumber)) return false;
PhoneNumber that = (PhoneNumber) o;
return Objects.equals(prefix, that.prefix) && Objects.equals(middle, that.middle) && Objects.equals(suffix, that.suffix);
}
@Override public int hashCode() {
if (hashCode != 0) {
return hashCode;
}
int result = prefix.hashCode();
result = 31 * result + middle.hashCode();
result = 31 * result + suffix.hashCode();
hashCode = result;
return result;
}
}
Java
복사
⇒ 이 때, 핵심 필드는 모두 해시코드를 계산할 때 포함해야한다. 핵심 필드가 누락되면서 해시의 신뢰도가 떨어지면 해시테이블의 성능역시 떨어질 수 있다.
물론, AutoValue , Lombok과 같은 라이브러리를 사용한다면 애노테이션으로 자동으로 equals and hashCode를 제공해주고, 일부 IDE에서도 이런 기능을 제공해준다.
그렇기에 사실 크게 equals and hashCode에 대해 생각안하고 사용할 수 있는데, 이런 규약들에 대해 기억하고 사용한다면 좀 더 나은 성능개선을 할 수 있다. 가령 예를들어 성능이 몹시 중요해서 Objects 클래스의 hash 메서드를 사용하는게 권장되지 않는 상황에서 IntelliJ IDE의 자동생성 기능으로 hashCode를 사용하면 기본적으로 Objects의 hash로 hashCode를 구현한다. 그렇기에 이런 부분에 대한 학습이 필요하다.
12. toString을 항상 재정의하라
Object 클래스에서 기본적으로 제공하는 toString 메서드는 우리가 보기에 적절하지는 않다.
public class PhoneNumber {...}
...
PhoneNumber phoneNumber = new PhoneNumber("010", "1234", "5678");
System.out.println("phoneNumber = " + phoneNumber.toString());
Java
복사
toString()메서드를 재정의하지 않은상태로 위와같이 생성한 phoneNumber 객체의 toString()을 호출하면 다음과 같은 결과가 출력된다.
•
phoneNumber = me.catsbi.effectivejavastudy.chapter2.item12.PhoneNumber@59e4bcf
이처럼 단순히 클래스명@16진수로_표시한_해시코드 를 반환할 뿐이다.
toString의 일반 규약인 간결하고 사람이 읽기 쉬운 형태의 유익한 정보는 아닌 것 같다.
phonNumber 객체에서 간결하고 읽기 쉬운 형태라면 아무래도 010-1234-5678 이런 형태가 아닐까 싶다.
toString()을 잘 구현할 경우
•
사용자 입장에서 객체 정보를 한 눈에 확인할 수 있다.
•
해당 클래스를 사용한 시스템은 디버깅하기 쉽다.
⇒ 내가 직접 호출하지 않더라도 여러 경우에서 시스템 자체적으로 호출한다.
디버깅을 할 때 역시 PhoneNumber@59e4bcf에 연결할 수 없습니다. 보다는 010-1234-5678에 연결할 수 없습니다.라는 결과가 알아보기 쉽다.
•
이 (toString을 재정의한)인스턴스를 포함하는 객체에서 유용하게 사용된다.
⇒ {Catsbi: PhoneNumber@59e4bc} 보다는 {Catsbi: 010-2912-8804}가 읽기 쉽다.
toString() 포맷 문서화 여부
•
toString()을 재정의할 때 반환값의 포맷을 문서화 할지 정해야 한다.
•
전화번호나 행렬같은 값 클래스는 문서화하는걸 권장한다.
◦
포맷을 명시하기로 했다면, 포맷에 맞는 문자열과 객체를 상호 전환 가능한 정적 팩토리나 생성자를 함께 제공해주는게 좋다.
◦
포맷을 지정하면 해당 포맷에 얽메이게 된다는 단점이 있다.
•
포맷 유무와 상관없이 의도가 명확히 드러나야 한다.
•
포맷을 명시하기로 했을 경우 예시
/**
* 전화번호의 문자열 표현을 반환한다.
* 이 문자열은 "XXX-YYYY-ZZZZ" 형태의 12글자다.
* XXX는 통신사 식별번호, YYYY는 일련번호 앞자리, ZZZZ는 일련번호 뒷자리이다.
* 각각의 대문자는 10진수 숫자 하나를 나타낸다.
*
* 전화번호의 각 부분이 값이 너무 작아 자릿수를 채울 수 없는 경우,
* 앞에서부터 0으로 채워나간다.(ex: 123 -> 0123)
* @return
*/
@Override
public String toString() {
return String.format("%03d-%03d-%04d", prefix, middle, suffix);
}
Java
복사
•
포맷을 명시하지 않기로 했을 경우 예시
/**
* 전화번호 정보를 반환한다.
* 기본적으로 다음과 같은 정보를 포함한다.
* "[통신사 식별번호: 010, 일련번호 앞자리: 1234, 일련번호 뒷자리: 5678]"
* @return
*/
@Override
public String toString() {...}
Java
복사
toString() 재정의가 필요없는 경우
•
정적 유틸리티 클래스
•
열거 타입(이미 자바에서 충분한 toString을 제공한다)
13. clone 재정의는 주의해서 진행하라
Cloneable
복제해도 되는 클래스임을 명시하는 믹스인 인터페이스(mixin interface)
객체를 복사하고싶다면 Cloneable 인터페이스를 구현해 clone 메서드를 재정의하는 방법이 일반적이다.
하지만, clone 메서드가 정의된곳은 Cloneable이 아닌 Object에 접근제어자도 protected이다.
그렇기에 Cloneable 인터페이스를 구현하는것만으로는 clone 메서드 호출이 안된다.
Cloneable의 역할
•
(Object 클래스의) clone 메서드의 동작 방식을 결정한다.
⇒ Cloneable을 구현하지 않은 클래스에서 clone을 호출하면 CloneNotSupportedException이 발생한다.
package me.catsbi.effectivejavastudy.chapter2.item13;
public class App {
public static void main(String[] args) throws CloneNotSupportedException {
Food apple = new Food("사과", 1000);
Food copiedApple = (Food) apple.clone();
System.out.println("copiedApple = " + copiedApple);
}
static class Food {
private String name;
private long price;
public Food(String name, long price) {
this.name = name;
this.price = price;
}
@Override
public String toString() {
return "Food{name='" + name + '\'' + ", price=" + price + '}';
}
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}
}
Java
복사
clone 메서드를 재정의하고 사용했지만 Cloneable을 구현하지 않았기에 예외가 발생한다.
clone 메서드의 규약
•
x.clone() ≠ x
•
x.clone().getClass() == x.getClass()
•
(optional) x.clone.equals(x)
위 규약을 보면 clone() 메서드의 반환값이 복사될 객체를 가르키기에 생성자 연쇄(constructor chaining)와 유사한데 이 말인즉슨 clone 내부 로직이 생성자를 호출해 얻은 인스턴스를 반환해도 문제가 없다는 것.
하지만, 이렇게 되면 해당 클래스의 하위클래스에서 super.clone()으로 호출할 때 상위 객체에서 잘못된 클래스가 생성될 수 있기에 위험하다.
(다만, clone을 재정의한 클래스가 final이라면 하위 클래스가 없기 때문에 상관없다.)
문제점
클래스의 모든 필드가 기본타입이거나 불변 객체를 참조한다면 super.clone()만으로도 문제없이 동작한다.
public class Food implements Cloneable{
private String name;
private long price;
@Override
public Food clone() throws CloneNotSupportedException {
try{
return (Food)super.clone();
}catch(CloneNotSupportedException e){
throw new AssertionError();//일어날 수 없는 일이다.
}
}
}
Java
복사
이와 같이 객체의 필드가 String, long으로 모두 기본타입(primitive type)일 경우 clone 메서드에서 super.clone() 호출만으로 정상적으로 동작을 한다. 자바는 공변 반환 타이핑(covariant return typing)을 지원하기에 위 코드처럼 반환타입을 상위클래스의 하위 타입으로 형변환 해 줄 수 있다.
더하여, try-catch 블록으로 감싸 CloneNotSupportedException 예외를 대응했지만 우리는 이 예외가 발생하지 않을 것임을 안다. 그렇기에 이런 코드는 비검사 예외(unchecked exception)이였어야 한다는 신호가 된다.
하지만,
가변 객체를 참조하는 클래스의 clone을 재정의할 때 이렇게 간단히 작성을 한다면 문제가 발생할 수 있다.
다음 코드는 다수의 Food를 관리하는 일급 콜렉션 클래스 Foods다.
public class Foods implements Cloneable{
private static final int BUFFER_SIZE = 16;
private Food[] store = new Food[BUFFER_SIZE];
private int size;
public Foods() {
}
public void add(Food food) {
ensureCapacity();
store[size++] = food;
}
private void ensureCapacity() {
if (store.length == size) {
store = Arrays.copyOf(store, 2 * size + 1);
}
}
public Food get(int index) {
if (index < size) {
Food result = store[index];
remove(index);
return result;
}
throw new IllegalArgumentException();
}
private void remove(int index) {
if (store.length - index >= 0) {
System.arraycopy(store, index + 1, store, index, store.length - 1 - index);
size--;
}
}
public Food[] getAll() {
return store.clone();
}
@Override
protected Object clone(){
try{
return (Foods)super.clone();
}catch(CloneNotSupportedException e){
throw new AssertionError();
}
}
}
Java
복사
이 일급 콜렉션 클래스에서 clone을 수행해서 나오는 복사된 객체는 store필드의 참조주소로 원본과 동일한 콜렉션을 참조할 것이다. 그렇기에 원본과 복제본의 store 필드가 서로에게 영향을 주게되면서 불변식을 해치게되고 데이터 오염이 일어날 수 있다.
•
문제 발생 예제
public class App {
public static void main(String[] args) throws CloneNotSupportedException {
Foods foods = new Foods();
foods.add(new Food("사과", 1000));
foods.add(new Food("귤", 2000));
foods.add(new Food("배", 3000));
Foods copiedFoods = foods.clone();
System.out.println("foods: "+ foods);
System.out.println("copiedFoods: "+copiedFoods);
copiedFoods.add(new Food("딸기", 500));
System.out.println("foods: "+ foods);
System.out.println("copiedFoods: "+copiedFoods);
}
}
Java
복사
복제본에만 딸기 객체를 추가했지만 원본의 store에도 딸기가 추가되있는걸 확인할 수 있다.
대안책
재귀적 호출
가장 쉬운 방법으로 객체의 참조 변수나 배열의 clone을 재귀적으로 호출해 주는것이다.
@Override
public Foods clone(){
try{
Foods result = (Foods) super.clone();
result.store = store.clone();
return result;
}catch(CloneNotSupportedException e){
throw new AssertionError();
}
}
Java
복사
⇒ final 필드는 새로운 값을 할당할 수 없기에 위 코드가 동작을 안한다.
⇒ 복제가능한 클래스를 만들기위해서는 final을 해제해야하는 필드들도 있다.
재귀적 호출의 한계점
내부적으로 배열을 재귀적 호출로 clone을 호출해줘서 해결을 할 수 있었지만, 이 배열이 객체 배열이고 연결리스트라면 원본과 복사본은 같은 연결 리스트(배열)을 참조하여 의도치 않게 동작할 수 있다. 다음 코드를 보자. Hashtable의 일부분을 가져온 CustomTable 클래스다.
import lombok.Data;
import java.util.Arrays;
import java.util.Objects;
@Data
public class CustomTable implements Cloneable{
private final int BUFFER_SIZE = 16;
private int size;
private Entry[] buckets = new Entry[BUFFER_SIZE];
public CustomTable() {}
public void put(Object key, Object obj) {
ensureCapacity();
Entry entry = new Entry(key, obj, null);
linkEntry(entry);
buckets[size++] = entry;
}
public Object get(Object key) {
if (size == 0) {
throw new RuntimeException();
}
return Arrays.stream(buckets)
.filter(bucket->bucket.key.equals(key))
.findFirst()
.map(Entry::getValue)
.orElseThrow(IllegalArgumentException::new);
}
private void linkEntry(Entry entry) {
if (size > 0) {
buckets[size-1].setNext(entry);
}
}
private void ensureCapacity() {
if (buckets.length == size) {
buckets = Arrays.copyOf(buckets, 2 * size + 1);
}
}
@Data
private static class Entry{
final Object key;
Object value;
Entry next;
public Entry(Object key, Object value, Entry next) {
this.key = key;
this.value = value;
this.next = next;
}
}
@Override
public CustomTable clone(){
try{
CustomTable result = (CustomTable) super.clone();
result.buckets = buckets.clone();
return result;
}catch(CloneNotSupportedException e){
throw new AssertionError();
}
}
@Override
public String toString() {
Entry bucket = buckets[0];
String[] result = new String[size+1];
int count = 0;
if (Objects.isNull(bucket)) return null;
while (Objects.nonNull(bucket.getNext())) {
result[count++] = bucket.value.toString();
bucket = bucket.next;
}
result[count] = bucket.value.toString();
return Arrays.toString(result);
}
}
Java
복사
public static void main(String[] args) {
CustomTable ct = new CustomTable();
ct.put("first", new Food("사과", 1000));
ct.put("second", new Food("포도", 2000));
ct.put("third", new Food("수박", 3000));
CustomTable copiedCT = ct.clone();
copiedCT.put("four", new Food("귤", 1111));
System.out.println("copiedCT = " + copiedCT);
System.out.println("ct = " + ct);
}
Java
복사
복사된 CustomTable객체에만 Food 객체를 추가해줬는데, 결과는 의도와 다르다.
이를 해결하기위해서는 버킷을 구성하는 연결리스트를 복사해야 한다.
DeepCopy
깊은 복사(deep copy)를 이용해 위와같은 문제를 해결한다.
1.
inner class Entry에 deepCopy 메서드 추가.
Entry deepCopy() {
return new Entry(key, value, next == null
? null
: next.deepCopy());
}
Java
복사
⇒ 엔트리가 가르키는 연결리스트 노드를 재귀적으로 복사한다.
2.
clone 메서드 수정
@Override
public CustomTable clone() {
try {
CustomTable result = (CustomTable) super.clone();
result.buckets = new Entry[buckets.length];
Entry current = buckets[0].deepCopy();
int count = 0;
while (!current.hasNext()) {
result.buckets[count++] = current;
current = current.next;
}
result.buckets[count] = current;
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
Java
복사
⇒ 예제코드와는 다르게 작성되있는데 이는, 예제코드(책)에서는 연결리스트에서 최초노드에서 생성된 next 인스턴스와 실제 next노드의 인스턴스가 각기 다르기 때문이다.
깊은복사(deep copy)를 지원하도록 코드를 수정해보았다. 우선 적절한 크기로 buckets 객체 배열을 생성 후 버킷을 순회하며 깊은복사된 새로운 엔트리를 삽입해주었다.
하지만, 이런 재귀호출 방식은 리스트의 원소 숫자만큼 스택 프레임을 낭비하기에 스택오버플로우 예외가 발생할 수 있다.
⇒ 재귀호출대신 반복자를 사용해서 작성해보기.
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;
}
Java
복사
이밖에 로직에 super.clone으로 객체 필드를 초기화 해준 뒤 put이나 setter를 직접 호출해서 내용을 동일하게 해주는 고수준 API 활용방법도 있지만, 속도가 느리고 필드 단위 객체 복사를 우회하는 방법이기에 Cloneable 아키텍처와는 어울리지 않는다.
주의점
•
생성자에서는 재정의될 수 있는 메서드를 호출하지 않아야 하며 이는 clone메서드도 동일하다.
⇒ 만약, clone이 하위 클래스에서 재정의한 메서드를 호출하면 하위 클래스는 복제 과정에서 자신의 상태를 바꿀 기회가 사라지며 복제본과 원본의 상태가 달라질 수 있다.
•
재정의한 clone 메서드는 throws 절을 없애야 한다
•
상속용 클래스는 Cloneable을 구현해서는 안된다.
⇒ 아니면 이렇게 구현하여 clone을 재정의하지 못하게 할 수 있다.
@Override
protected final Object clone() throws CloneNotSupportedException {
throw new CloneNotSupportedException();
}
Java
복사
•
Cloneable을 구현하는 모든 클래스는 clone을 재정의 해야 한다.
추천: 복사 생성자와 복사 팩터리
(정확한 이름은 변환 생성자와 변환팩터리이다)
이미 Cloneable을 구현한 클래스는 어쩔 수 없지만 그게 아니라면 복사 생성자와 복사 팩토리라는 객체 복사 방식을 고려할만 하다.
•
복사 생성자
public CustomTable(CustomTable ct){ ... };
Java
복사
•
복사 팩터리
public static CustomTable newInstance(CustomTable ct){ ... };
Java
복사
이 두 방식이 Cloneable/clone에 비교해서 나은점은 다음과 같다.
•
언어 모순적이고 생성자를 쓰지않는 객체 생성 메커니즘을 사용하지 않는다.
•
정상적인 final 필드 용법과도 충돌하지 않는다.
•
불필요한 예외가 발생하지 않는다.
•
형변환도 필요하지 않다.
•
해당 클래스가 구현한 '인터페이스'타입의 인스턴스를 인수로 받을 수 있다.
⇒ Ex: HashSet을 TreSet 타입으로 복제할 수 있다.
정리
•
인터페이스(클래스)를 새로 만들때는 Cloneable을 확장하지 말아라.
•
복사가 필요하면 복제 생성자 및 팩터리를 사용하는게 좋다.
•
오직 배열만이 clone 메서드 방식의 사용이 권장된다.
14. Comparable을 고려하라
객체간 정렬이 필요할 때 사용하는 인터페이스는 Comparator, Comparable이 있다.
여기서 Comparable 인터페이스는 compareTo라는 하나의 메서드를 정의하는데, 이 메서드는 Object 메서드가 아니다. 이 메서드의 성격은 Object의 equals와 유사한데, 동치성 비교뿐아니라 순서까지 비교가 가능하고 제네릭하다는 차이점이 있다.
자바에서 제공하는 모든 값 클래스와 열거 타입이 Comparable을 구현했다.
알파벳, 숫자, 연대, 번호등 순서가 있는 값 클래스를 만들 때 Comparable 를 구현하자.
규약
compareTo메서드의 일반 규약은 equals과 비슷하다.
Comparable 을 구현한 객체는 다음 규약들을 지켜야 한다.
•
해당 객체와 주어진(매개변수) 객체의 순서를 비교한다.
⇒ 해당 객체가 더 크다면 양수를 반환한다.
⇒ 해당 객체가 더 작다면 음수를 반환한다.
⇒ 해당객체와 주어진 객체가 같을경우 0을 반환한다.
⇒ 해당 객체와 비교할 수 없는 타입의 객체가 전달되면 ClassCastException이 발생한다.
•
대칭성을 보장해야 한다.
⇒ 모든 x, y클래스에 대해서 sgn(x.compareTo(y) == -sgn(y.compareTo(x))여야 한다.
⇒ x.compareTo(y)는 y.compareTo(x)가 예외를 던질 때에 한해서 예외가 발생해야 한다.
•
추이성을 보장해야 한다.
객체 x, y, z가 있다고 할 때
⇒ x.compareTo(y)가 양수이고 y.compareTo(z)도 양수라면, x.compareTo(z)도 양수여야한다.
(x > y && y > z 이면 x > z여야 한다.)
•
x.compareTo(y) == 0 일 때 sgn(x.compareTo(z)) == sgn(y.compareTo(z))이어야 한다.
•
x.compareTo(y) == 0 일 때 x.equals(y)어야 한다.
⇒ 필수는 아니지만 지키는게 좋다.
equals 규약과의 차이점
대칭성, 추이성, 반사성 규약을보면 equals와 규약들이 비슷하다. 하지만, 모든 객체에 대해 전역 동치관계를 부여하는 equals와 다르게 compareTo는 타입이 다른 객체를 신경쓰지 않아도 된다.
다를 경우 ClassCastException을 던지면 그만이다.
작성 요령
equals와 비슷하지만, Comparable은 타입을 인수로 받는 제네릭 인터페이스라서 compareTo 메서드의 인수 타입은 컴파일타임에 정해진다. (입력 인수의 타입을 확인및 형변환 할 필요가 없다는 의미)
•
compareTo 메서드는 각 필드의 동치관계를 보는게 아니라 그 순서를 비교한다.
•
객체 참조 필드를 비교하려면 compareTo 메서드를 재귀적으로 호출한다.
•
Comparable을 구현하지 않은 필드나 표준이 아닌 순서로 비교해야 할 경우 Comparator를 쓰면 된다.
•
기본적인 사용 사례
public final class CaseInsensitiveString
implements Comparable<CaseInsensitiveString>{
private final String str;
public CaseInsensitiveString(String str) {
this.str = Objects.requireNonNull(str);
}
@Override
public int compareTo(CaseInsensitiveString cis) {
return String.CASE_INSENSITIVE_ORDER.compare(str, cis.str);
}
}
Java
복사
객체 참조 필드가 하나뿐인 비교자
⇒ item10에서 작성했던 CaseInsensitiveString 클래스이고 Comparable을 구현해 대소문자 구분없이 문자열 비교자를 구현했다.
•
기본타입(Primitive type)은 Java 7 이후 추가된 박싱 기본 타입 클래스의 정적 메서드 compare를 이용하자.
public class PhoneNumber implements Comparable<PhoneNumber>{
private final int prefix;
private final int middle;
private final int suffix;
@Override
public int compareTo(PhoneNumber pn) {
int result = Integer.compare(prefix, pn.prefix);
if (result == 0) {
result = Integer.compare(middle, pn.middle);
if(result == 0)
result = Integer.compare(suffix, pn.suffix);
}
return result;
}
}
Java
복사
⇒ 중요도에 따라 우선순위로 비교한다.
비교자 생성 메서드(comparator construction method)
•
자바 8 부터는 Comparator 인터페이스가 비교자 생성 메서드를 이용해 메서드 연쇄 방식으로 비교자를 생성할 수 있게 되었다.
•
방식은 간결하지만 성능은 떨어진다.
public static final Comparator<PhoneNumber> COMPARATOR =
comparingInt(PhoneNumber::getPrefix)
.thenComparingInt(PhoneNumber::getMiddle)
.thenComparingInt(PhoneNumber::getSuffix);
@Override
public int compareTo(PhoneNumber pn) {
return COMPARATOR.compare(this, pn);
}
Java
복사
⇒ 이 코드는 클래스 초기화시 비교자 생성 메서드 2개를 이용해 비교자를 생성한다.
⇒ 최초 comparingInt에서는 객체 참조를 int 타입 키에 매핑하는 키 추출 함수(key extractor function)을 인수로 받아 해당 키를 기준으로 순서를 정하는 비교자를 반환하는 정적 메서드다.
⇒ 위 예제와 에서는 Method Reference를 사용을 편하게 했지만, 직접 필드 접근으로 꺼내거나 람다식으로 꺼낼 경우 인자의 타입을 명시적으로 작성해줘야 한다.
comparingInt((PhoneNumber pn) → pn.prefix)
자바에서 여기까지 타입추론을 제대로 하지 못하기 때문에 명시해 줄 필요가 있다.
물론, 두 번째 호출부터는 타입추론이 제대로 동작한다.
정적 메서드 혹은 비교자 생성 메서드를 활용하자.
객체간 순서를 정한다고 해시코드를 기준으로 정렬하기도하는데 단순히 첫 번째 값이 크면 양수, 같으면 0, 첫 번째 값이 작으면 음수를 반환한다는 것만 생각해서 다음과 같이 작성을해선 안된다.
static Comparator<Object> hashCodeOrder = new Comparator<>() {
public int compare(Object 01, Object 02) {
return ol.hashCode() - o2.hashCode();
}
}
Java
복사
추이성을 위배하는 비교자
이런 방식은 얼핏보면 문제 없을 것 같지만 정수 오버플로 혹은 IEEE754 부동소수점 계산 방식에 따른 오류를 낼 수 있다. 게다가 속도가 엄청 빠르지도 않다.
대신, 다음처럼 정적 compare메서드 혹은 비교자 생성 메서드를 활용해보자.
static Comparator<Object hashCodeOrder = new Comparator<>(){
public int compare(Object o1, Object 02){
return Integer.compare(01.hashCode(), o2.hashCode());
}
}
Java
복사
정적 compare 메서드를 활용한 비교자
static Comparator<Object> hashCodeOrder =
Comparator.comparingInt(Object::hashCode);
Java
복사
정리
•
순서를 고려해야하는 값 클래스는 Comparable인터페이스를 꼭 구현하면 좋다.
•
compareTo 메서드에서는 < , >같은 연산자는 쓰지 않아야 한다.
•
박싱된 기본 타입 클래스가 제공하는 정적 compare 메서드나 Comparator 인터페이스가 제공하는 비교자 생성 메서드를 활용하자.