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로 구현해서는 안된다. 자세한 건 이 글을 참고하자.)