equals
를 재정의하지 않아도 되는 상황에서는 재정의를 안하는 게 최선이다.
equals
로 충분한 경우private
or package-private
이고, equals
메서드 호출할 일이 없는 경우equals 메서드는 반사성, 대칭성, 추이성, 일관성, null 아님을 따라랴 한다.
x.equals(x) == true
당연하게 객체는 자기 자신과 비교했을 때 같아야 한다는 뜻이다.
x.equals(y) == y.equals(x)
@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;
}
이렇게equals
를 재정의 하는 것은 자연스러운 것처럼 보인다. CaseInsensitiveString
클래스는 String
객체가 와도 CaseInsensitiveString
타입으로 바꿔서 비교하기 때문이다.
근데 String
은 CaseInsensitiveString
을 전혀 알지 못한다!
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String polish = "polish";
System.out.println(cis.equals(polish)); //true
System.out.println(polish.equals(cis)); //false
그래서 polish.equals(cis)
이 결과가 false
가 나오기 때문에 대칭성에 위배된다.
x.equals(y) == true
,y.equals(z) == true
➡️x.equals(z) == true
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 this.x == p.x && this.y == p.y;
}
}
class ColorPoint extends Point {
private final Color color;
@Override
public boolean equals(Object o) {
if(!(o instanceof ColorPoint)) return false;
return super.equals(o) && this.color == ((ColorPoint) o).color;
}
}
Point
클래스와, Point
클래스를 상속 받는 ColorPoint
클래스가 있다고 가정하자.
ColorPoint
의 equals
는 어떻게 재정의할까?
@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 cp1 = new ColorPoint(1, 2, Color.RED);
Point p = new Point(1, 2);
ColorPoint cp2 = new ColorPoint(1, 2, Color.BLUE);
cp1.equals(p) //true : ColorPoint의 equals(색상을 무시)
p.equals(cp2) //true : Point의 equals(색상을 무시)
cp1.equals(cp2)); //false : ColorPoint의 equals(색상 무시 X)
그리고 이것은 무한 재귀에 빠질 위험이 있다.
만약 Point
를 상속 받는 SmellPoint
를 만들었다고 가정하자.
그럼 SmellPoint
의 equals
는 다음과 같이 구현할 것이다.
@Override public boolean equals(Object o) {
if (!(o instanceof Point))
return false;
// o가 일반 Point면 색상을 무시하고 비교한다.
if (!(o instanceof SmellPoint))
return o.equals(this);
// o가 ColorPoint면 색상까지 비교한다.
return super.equals(o) && ((SmellPoint) o).smell.equals(smell);
}
SmellPoint sp = new SmellPoint(1, 0, "sweet");
ColorPoint cp = new ColorPoint(1, 0, Color.RED);
sp.equals(cp); //StackOverflowError
sp.equals(cp)
를 하면,
1. (SmellPoint
의) equals
가 호출 -> ColorPoint
객체이므로 두번째 if문에서 걸림
2. (ColorPoint
의) equals
가 호출 -> SmellPoint
객체이므로 두번째 if문에서 걸림
3. 1번 2번 무한 반복 -> StackOverflowError
지금까지 Point
에 color
를 추가한 ColorPoint
에서의 equals
의 잘못된 예시를 살펴보았다.
그럼 ColorPoint
를 Point
처럼 사용할 수 있게 하면서 equals
를 구현하는 방법은 무엇일까? 안타깝게도 구체클래스를 확장해 새로운 값을 추가하면서 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;
}
getClass()
로 두 객체를 비교하는 방법이 있지만 이것은 객체지향적이지 않다.
ColorPoint
객체를 Point
객체처럼 사용할 수 없기 때문이다.
➡️ 상속 대신 컴포지션을 사용하자.
컴포지션이란? 다른객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 메서드를 호출하는 기법
지금까지 Point
를 상속 받는 ColorPoint
클래스에 color
라는 필드를 추가했지만, 컴포지션을 이용해보자.
public class ColorPoint {
private final Point point; //Point 객체
private final Color color; //Color 객체
public ColorPoint(int x, int y, Color color) {
point = new Point(x, y);
this.color = Objects.requireNonNull(color);
}
/**
* 이 ColorPoint의 Point 뷰를 반환한다.
*/
// ColorPoint지만, Point 만을 밖에 노출시킬 수 있다.
// 이건 상위타입으로 반환할 수 없기 때문에 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);
}
}
ColorPoint
는 Point
를 상속받는 것이 아니라, Point
객체를 참조하고 있다. 더불어 Color
객체도 참조하고 있다.
이렇게 되면 당연히 Point point = new ColorPoint();
이렇게 상위 타입으로 변환해서 쓰지 못한다.
그래서 view 메서드
가 필요하다. view 메서드
는 ColorPoint
클래스에서 Point
만을 밖에 노출시킬 수 있다.
x.equals(y)
은 항상true
혹은false
를 반환해야 한다.
두 객체가 같다면 영원히 같다야 하고, 다르다면 영원히 달라야 한다.
java/net/URL의 equals는 이 규약을 지키지 않았다.
URL google1 = new URL("https", "about.google", "/products/");
URL google2 = new URL("https", "about.google", "/products/");
System.out.println(google1.equals(google2)); //IP가 다르면 false 가 나올 수 있다.
주어진 URL과 매핑된 호스트의 IP 주소를 비교하는데, ip 주소는 변경될 수 있기 때문이다. 이렇게 일관적이지 않게 구현하지 말자!
그냥 URL 문자열이 같다면 같은 URL로 판단하게 (복잡하지 않게) 구현해야 한다.
x.equals(null) == false
모든 객체가 null
과 같지 않아야 한다는 의미이다.
전형적인 equals
메서드의 예
@Override public boolean equals(Object o) {
if (o == this) // 1. 입력 객체가 자기 자신의 참조인지 확인
return true;
if (!(o instanceof PhoneNumber)) // 2. 올바른 타입이 들어왔느지 검사
return false;
PhoneNumber pn = (PhoneNumber)o; // 3. 형 변한
return pn.lineNum == lineNum && pn.prefix == prefix && pn.areaCode == areaCode; // 4. 핵심 필드들이 같은지 검사
}
인텔리제이에서는 equals
, hashcode
를 쉽게 재정의할 수 있게 해준다.
인텔리제이와 같은 툴을 이용해서 직접 equals
를 구현해주는 방법이 있지만
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Point point = (Point) o;
return x == point.x && y == point.y; // 필드 추가 시 이 부분이 바껴야 함
}
코드가 지저분해진다는 단점이 있고, 클래스에 int z
필드 추가 시 equals
메서드를 지우고 다시 만들어야 한다.
구글에서 만든 @AutoValue
는 값 클래스를 만들 때 재정의 해야 하는 equals
, toString
, hashCode
를 자동으로 컴파일 시점에 만들어 준다. 우리는 단지 추상클래스만 만들면 된다.
근데 @AutoValue
를 쓰면 지켜야 할 규약들이 생기고, 컴파일 시점에 .class
파일을 만들어주기 때문에 인텔리제이에서 빨간줄로 표현되어 불편하다는 단점이 있다.
구글에서도 만약 코틀린을 쓴다면 데이터클래스를, record를 지원하는 java 버전을 쓴다면 record
를 쓰라고 추천하고 있다.
보통은 롬복을 제일 많이 사용한다.
record
는 자바 14와 15에서 preview로 추가된 이후, 16버전에서 정식 스펙으로 올라왔다.
record
는 VO
, DTO
에서 사용하기 좋다.
DTO
를 구현하기 위해서는 getter
, setter
, equals
, hashCode
, toString
같은 메서드를 오버라이드 해야 하는데, 오버라이드할 때 아주 적은 부분만 수정한다. 그리고 코드가 지저분해진다는 단점이 있다. 이때 record
를 쓰면 자동으로 오버라이드 할 메서드들을 만들어준다.
위 Point
클래스를 record
로 구현하면 다음과 같다.
public record Point(int x, int y) {
}
System.out.println(p1.equals(p2)); //true
(주의할 점 : 자바빈즈를 대체하기 위한 기술은 아니며, entity를 record로 구현해서는 안된다. 자세한 건 이 글을 참고하자.)